From 2a993528fdbdeb13fd130e04c60ae3886d0932ae Mon Sep 17 00:00:00 2001 From: boyuc Date: Wed, 11 Mar 2026 14:26:56 +0800 Subject: [PATCH 01/65] Add fx_viewer in executorch --- backends/qualcomm/utils/fx_viewer/README.md | 303 +++++ backends/qualcomm/utils/fx_viewer/__init__.py | 16 + .../qualcomm/utils/fx_viewer/color_rules.py | 152 +++ .../examples/demo_fx_viewer_extensions.py | 208 ++++ backends/qualcomm/utils/fx_viewer/exporter.py | 357 ++++++ .../qualcomm/utils/fx_viewer/extension.py | 110 ++ .../qualcomm/utils/fx_viewer/grandalf/LICENSE | 564 +++++++++ .../utils/fx_viewer/grandalf/__init__.py | 1 + .../utils/fx_viewer/grandalf/graphs.py | 882 +++++++++++++ .../utils/fx_viewer/grandalf/layouts.py | 1095 +++++++++++++++++ .../utils/fx_viewer/grandalf/routing.py | 132 ++ .../fx_viewer/grandalf/utils/__init__.py | 3 + .../utils/fx_viewer/grandalf/utils/dot.py | 401 ++++++ .../fx_viewer/grandalf/utils/geometry.py | 214 ++++ .../utils/fx_viewer/grandalf/utils/linalg.py | 319 +++++ .../utils/fx_viewer/grandalf/utils/nx.py | 39 + .../utils/fx_viewer/grandalf/utils/poset.py | 155 +++ backends/qualcomm/utils/fx_viewer/models.py | 41 + .../utils/fx_viewer/templates/README.md | 128 ++ .../fx_viewer/templates/canvas_renderer.js | 617 ++++++++++ .../fx_viewer/templates/fx_graph_viewer.js | 233 ++++ .../fx_viewer/templates/graph_data_store.js | 218 ++++ .../fx_viewer/templates/minimap_renderer.js | 269 ++++ .../fx_viewer/templates/search_engine.js | 152 +++ .../utils/fx_viewer/templates/themes.js | 40 + .../utils/fx_viewer/templates/ui_manager.js | 516 ++++++++ .../fx_viewer/templates/view_controller.js | 403 ++++++ 27 files changed, 7568 insertions(+) create mode 100644 backends/qualcomm/utils/fx_viewer/README.md create mode 100644 backends/qualcomm/utils/fx_viewer/__init__.py create mode 100644 backends/qualcomm/utils/fx_viewer/color_rules.py create mode 100644 backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py create mode 100644 backends/qualcomm/utils/fx_viewer/exporter.py create mode 100644 backends/qualcomm/utils/fx_viewer/extension.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/LICENSE create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/__init__.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/graphs.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/layouts.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/routing.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/__init__.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/dot.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/geometry.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/linalg.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/nx.py create mode 100644 backends/qualcomm/utils/fx_viewer/grandalf/utils/poset.py create mode 100644 backends/qualcomm/utils/fx_viewer/models.py create mode 100644 backends/qualcomm/utils/fx_viewer/templates/README.md create mode 100644 backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/graph_data_store.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/search_engine.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/themes.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/ui_manager.js create mode 100644 backends/qualcomm/utils/fx_viewer/templates/view_controller.js diff --git a/backends/qualcomm/utils/fx_viewer/README.md b/backends/qualcomm/utils/fx_viewer/README.md new file mode 100644 index 00000000000..1a70faa9e9b --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/README.md @@ -0,0 +1,303 @@ +## What This is fx_viewer + +`fx_viewer` exports a PyTorch model graph and renders it as an interactive browser viewer. + +- Python side: + - traces/extracts FX graph + - computes layout (Grandalf/Sugiyama) + - builds payload (`base` + `extensions`) + - emits JSON/JS/HTML +- JavaScript side: + - renders graph canvas + minimap + - handles selection, search, zoom/pan + - toggles extension layers + - applies color-by mode + +## Why Yet Another Graph Visualizer? + +### Simplicity +- The whole visualization frontend is done within 2k lines of vallina Javascript (no library or dependency). +- No installation required, export standalone html that runs in any browser. + +### Integration & Customization +- Support Python extension to customize color display, insert additional data and labels, enable easily integration with executorch debuggers and profiling utilities. +- Simple JS API for embedding visualizer in custom HTML div, easily control or customize the interactive actions. + +### Performance +- Easily render 10k+ nodes, load the entire graph instantly. +- Much faster and smooth experience due to lightweight design and pre-computed graph layout. (compared to dagre based JS engines i.e. netron / model-explorer). + + + + +## Quick Start + +Use your executorch venv: + +```bash +source ~/executorch/.venv/bin/activate +``` + +Run extension demo (Swin + Llama): + +```bash +python examples/demo_fx_viewer_extensions.py --model both +``` + +This generates: +- `swin_graph_v3_extensions.html` +- `llama_graph_v3_extensions.html` + +## Python API + +Core API: + +```python +from fx_viewer import FXGraphExporter, GraphExtension, CategoricalColorRule + +ep_model = torch.export.export(model, inputs, strict=False) +graph_module = ep_model.graph_module + +exporter = FXGraphExporter(graph_module) + +ext = GraphExtension(id="backend_type", name="Backend Assignment") +ext.add_node_data("node_id1", {"backend": "cpu"}) +ext.add_node_data("node_id2", {"backend": "gpu"}) +ext.set_color_rule(CategoricalColorRule(attribute="backend")) + +exporter.add_extension(ext) +exporter.export_html("graph.html") +``` + +Export options: +- `generate_json_payload()`: return payload dict in memory +- `export_json(path)`: write payload as JSON file +- `export_js(container_id)`: return embeddable JS snippet +- `export_html(path)`: write standalone HTML + +## Canonical Data Contract + +The exporter now uses typed wire-format dataclasses: + +- `GraphNode`: + - `id`, `label`, `x`, `y`, `width`, `height`, `info`, `tooltip`, `fill_color` +- `GraphEdge`: + - `v`, `w`, `points` + +Formatters should consume `GraphNode` only. Required processing fields like +`name/op/target/args/kwargs` are read from `node.info`. + +### JSON Schema Mapping + +The payload is emitted with `dataclasses.asdict(...)`, so these dataclasses are +the JSON schema source of truth. + +`GraphNode` -> `base.nodes[]` + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | `str` | Node id (FX name) | +| `label` | `str` | Rendered title text | +| `x`, `y` | `float` | Layout position | +| `width`, `height` | `float` | Layout box size | +| `info` | `dict[str, Any]` | Core metadata used by search/info panel | +| `tooltip` | `list[str]` | Base tooltip lines | +| `fill_color` | `str \| None` | Optional node color | + +`GraphEdge` -> `base.edges[]` + +| Field | Type | Notes | +| --- | --- | --- | +| `v` | `str` | Source node id | +| `w` | `str` | Target node id | +| `points` | `list[{x: float, y: float}]` | Optional routed polyline | + +Top-level: +- `GraphPayload.base` -> `{legend, nodes, edges}` +- `GraphPayload.extensions` -> extension overlays keyed by extension id + +## Exporter Architecture (Phases) + +`FXGraphExporter.generate_json_payload()` is split into explicit phases: + +1. `trace_model()` +2. `extract_graph()` +3. `compute_layout()` +4. `build_base_payload()` +5. `build_extensions_payload()` + +This separation keeps behavior reviewable and testable. + +## JS Architecture + +The viewer is split into modules under `fx_viewer/templates/`: + +- `themes.js` +- `graph_data_store.js` +- `search_engine.js` +- `view_controller.js` +- `canvas_renderer.js` +- `minimap_renderer.js` +- `ui_manager.js` +- `fx_graph_viewer.js` + +Detailed JS API and load order are documented in: +- `fx_viewer/templates/README.md` + +## Information Flows + +Extension toggle flow: +1. UI checkbox/radio changes +2. `ViewerController.setState(...)` +3. `GraphDataStore.computeActiveGraph(...)` +4. minimap/legend/info refresh +5. full re-render + +Selection flow: +1. canvas click +2. controller computes ancestors/descendants +3. canvas + minimap highlight path +4. info panel shows merged metadata + +Search flow: +1. user types query +2. `SearchEngine.search(...)` +3. candidates shown in dropdown +4. hover/enter navigates or selects + +## Extension Authoring Guide + +`GraphExtension` adds optional node-level overlays on top of base graph structure. + +### Extension Working Logic (Key Contract) + +This is the most important extension contract: + +1. You populate extension data explicitly with `add_node_data(node_id, data)`. +2. `label_formatter(data)` and `tooltip_formatter(data)` receive exactly that stored `data` dict. +3. Formatters must only read keys that were explicitly written previously via `add_node_data(...)`. + +What formatters do **not** get automatically: +- full FX node object +- base graph node `info` +- global graph context + +Return contract: +- formatter output must be `list[str]` +- invalid output (or formatter exceptions) is ignored with warnings + +If you need base attributes (for example `target`, `op`) in extension label/tooltip, +copy them into extension `data` first, then read from formatter input. + +### Extension Skeleton + +```python +from fx_viewer import GraphExtension + +ext = GraphExtension(id="my_ext", name="My Extension") + +# Attach data to node ids from exported graph +ext.add_node_data("node_1", {"metric": 3.14, "tag": "hot"}) + +# Optional text inside node +ext.set_label_formatter(lambda data: [f"metric={data['metric']:.2f}"]) + +# Optional tooltip lines +ext.set_tooltip_formatter(lambda data: [f"tag={data['tag']}"]) +``` + +### Good vs Bad Formatter Usage + +```python +# GOOD: formatter reads only keys explicitly added before +ext.add_node_data(node_id, {"target": "aten.add", "latency_ms": 1.2}) +ext.set_label_formatter(lambda data: [f"target={data['target']}"]) +ext.set_tooltip_formatter(lambda data: [f"latency={data['latency_ms']}"]) + +# BAD: formatter assumes implicit fields that were never added +ext.set_label_formatter(lambda data: [f"shape={data['tensor_shape']}"]) # KeyError risk +``` + +Validation behavior: +- extension `id` and `name` must be non-empty +- extension IDs must be unique within one exporter +- formatters must return `list[str]` +- formatter failures emit warnings with extension/node context + +### Color Rules + +Color rules map extension data to `fill_color` and legend entries. + +Available rules: +- `CategoricalColorRule(attribute, color_map=None)` +- `NumericColorRule(attribute, cmap="viridis", handle_outliers=True)` + +#### CategoricalColorRule + +Use for string-like buckets (`op type`, `device`, `stage`). + +```python +from fx_viewer import CategoricalColorRule + +ext.set_color_rule(CategoricalColorRule( + attribute="op", + color_map={"conv": "#ff9999", "linear": "#99ccff"} +)) +``` + +Behavior: +- if `color_map` has a key, use it +- otherwise, deterministic hash-based color is generated +- legend is stable across runs for same values + +Use when: +- categories are discrete +- relative ordering is not meaningful + +#### NumericColorRule + +Use for continuous metrics (`latency`, `memory`, `topo_index`). + +```python +from fx_viewer import NumericColorRule + +ext.set_color_rule(NumericColorRule( + attribute="latency_ms", + cmap="viridis", # or Reds/Blues/Greens + handle_outliers=True +)) +``` + +Behavior: +- normalizes values into `[min, max]` +- optional percentile clipping for outliers +- generates 5-step legend + +Use when: +- magnitude matters +- you want heatmap-like visual scanning + +### Practical Rule Selection + +- Prefer categorical when the value domain is small and semantic. +- Prefer numeric when values are measured quantities. +- For noisy metrics with extreme spikes, keep `handle_outliers=True`. +- For rank/index-like fields (`topological_order`), set `handle_outliers=False`. + +## Testing + +Contract tests live in: +- `tests/test_exporter_contract.py` + +They validate: +- default payload shape +- custom base label/tooltip formatter behavior +- extension merge behavior +- color rule legend stability + +Run: + +```bash +source ~/executorch/.venv/bin/activate +pytest -q tests/test_exporter_contract.py +``` diff --git a/backends/qualcomm/utils/fx_viewer/__init__.py b/backends/qualcomm/utils/fx_viewer/__init__.py new file mode 100644 index 00000000000..8c99cb7bf76 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/__init__.py @@ -0,0 +1,16 @@ +from .color_rules import ColorRule, CategoricalColorRule, NumericColorRule +from .exporter import FXGraphExporter +from .extension import GraphExtension +from .models import BaseGraphPayload, GraphEdge, GraphNode, GraphPayload + +__all__ = [ + "FXGraphExporter", + "GraphExtension", + "ColorRule", + "CategoricalColorRule", + "NumericColorRule", + "GraphNode", + "GraphEdge", + "BaseGraphPayload", + "GraphPayload", +] diff --git a/backends/qualcomm/utils/fx_viewer/color_rules.py b/backends/qualcomm/utils/fx_viewer/color_rules.py new file mode 100644 index 00000000000..5f79197dd64 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/color_rules.py @@ -0,0 +1,152 @@ +"""Color rules for mapping node attributes to display colors.""" + +import hashlib +import colorsys + +class ColorRule: + """Base class for node->color mapping.""" + def __init__(self, attribute: str): + self.attribute = attribute + + def apply(self, nodes_data: dict) -> tuple[dict, list]: + """ + Takes a dictionary mapping node_id -> node_info_dict. + Returns: + - node_colors: Dict[str, str] mapping node_id -> hex color. + - legend: List[Dict[str, str]] containing legend items {"label": ..., "color": ...}. + """ + raise NotImplementedError + +class CategoricalColorRule(ColorRule): + """Assign deterministic colors to string/categorical values.""" + def __init__(self, attribute: str, color_map=None): + super().__init__(attribute) + self.color_map = color_map or {} + + def apply(self, nodes_data: dict) -> tuple[dict, list]: + node_colors = {} + unique_values = set() + + for node_id, data in nodes_data.items(): + if self.attribute not in data: + continue + + val = data[self.attribute] + if val is None: + continue + + val_str = str(val) + unique_values.add(val_str) + + if val_str in self.color_map: + node_colors[node_id] = self.color_map[val_str] + else: + # Consistent hashing to a hue value in HSV space + hash_val = int(hashlib.md5(val_str.encode('utf-8')).hexdigest(), 16) + hue = (hash_val % 360) / 360.0 + saturation = 0.65 + value_hsv = 0.85 + + r, g, b = colorsys.hsv_to_rgb(hue, saturation, value_hsv) + r, g, b = int(r * 255), int(g * 255), int(b * 255) + node_colors[node_id] = f"#{r:02x}{g:02x}{b:02x}" + + # Generate Legend + legend = [] + # First add explicit map entries + for k, v in self.color_map.items(): + if k in unique_values: + legend.append({"label": str(k), "color": v}) + unique_values.remove(k) + + # Then add hashed ones + for val_str in sorted(unique_values): + # Recalculate hash for the legend to avoid storing it twice + hash_val = int(hashlib.md5(val_str.encode('utf-8')).hexdigest(), 16) + hue = (hash_val % 360) / 360.0 + r, g, b = colorsys.hsv_to_rgb(hue, 0.65, 0.85) + hex_color = f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}" + legend.append({"label": str(val_str), "color": hex_color}) + + return node_colors, legend + +class NumericColorRule(ColorRule): + """Assign gradient colors to numeric values.""" + def __init__(self, attribute: str, cmap="viridis", handle_outliers=True): + super().__init__(attribute) + self.cmap = cmap + self.handle_outliers = handle_outliers + + def _interpolate_color(self, ratio): + ratio = max(0.0, min(1.0, ratio)) + + if self.cmap.lower() == 'reds': + r = 255 + g = b = int(127 * (1 - ratio) + 128) + elif self.cmap.lower() == 'blues': + r = g = int(127 * (1 - ratio) + 128) + b = 255 + elif self.cmap.lower() == 'greens': + r = b = int(127 * (1 - ratio) + 128) + g = 255 + else: # viridis-like fallback + if ratio < 0.5: + r, g, b = 68 + 50, int(1 + ratio * 2 * 120)+ 50, int(34 + ratio * 2 * 50) + 50 + else: + r = int(68 + (ratio - 0.5) * 2 * 187) + g = int(171 + (ratio - 0.5) * 2 * 84) + b = int(134 - (ratio - 0.5) * 2 * 134) + + return f"#{r:02x}{g:02x}{b:02x}" + + def apply(self, nodes_data: dict) -> tuple[dict, list]: + # Pass 1: Collect valid values for fitting + valid_values = [] + for data in nodes_data.values(): + if self.attribute in data: + val = data[self.attribute] + if isinstance(val, (int, float)): + valid_values.append(val) + + if not valid_values: + return {}, [] + + # Fit bounds + if self.handle_outliers and len(valid_values) > 10: + valid_values.sort() + p5_idx = max(0, int(len(valid_values) * 0.05)) + p95_idx = min(len(valid_values) - 1, int(len(valid_values) * 0.95)) + _min = valid_values[p5_idx] + _max = valid_values[p95_idx] + else: + _min = min(valid_values) + _max = max(valid_values) + + if _min == _max: + _max = _min + 1e-9 + + # Pass 2: Calculate colors + node_colors = {} + for node_id, data in nodes_data.items(): + if self.attribute in data: + val = data[self.attribute] + if isinstance(val, (int, float)): + ratio = (val - _min) / (_max - _min) + node_colors[node_id] = self._interpolate_color(ratio) + + # Generate Legend + legend = [] + for i in range(5): + ratio = i / 4.0 + val = _min + ratio * (_max - _min) + color = self._interpolate_color(ratio) + + if abs(val) >= 1000 or (abs(val) < 0.01 and val != 0): + label_str = f"{val:.2e}" + elif isinstance(val, int) or float(val).is_integer(): + label_str = f"{int(val)}" + else: + label_str = f"{val:.2f}" + legend.append({"label": label_str, "color": color}) + + return node_colors, legend diff --git a/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py new file mode 100644 index 00000000000..37bcae95362 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Demo for fx_viewer V3 extensions with Swin and Llama models. + +This script exports standalone HTML files using the new fx_viewer module and +adds two extension layers: +1) Target/op-type categorical coloring. +2) Topological-order numeric coloring. + +Run (from repo root): + source ~/executorch/.venv/bin/activate + python examples/demo_fx_viewer_extensions.py --model both +""" + +from __future__ import annotations + +import argparse +from collections import deque +from pathlib import Path +from typing import Any +import sys + +import torch + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from executorch.backends.qualcomm.debugger.fx_viewer import ( + CategoricalColorRule, + FXGraphExporter, + GraphExtension, + GraphNode, + NumericColorRule, +) + + +def _base_label(node: GraphNode) -> str: + target = str(node.info.get("target") if node.info.get("op") == "call_function" else node.info.get("op")) + return target.replace("aten.", "").replace(".default", "") + +def _compute_topological_index(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> dict[str, int]: + node_ids = [n["id"] for n in nodes] + indeg = {nid: 0 for nid in node_ids} + adj: dict[str, list[str]] = {nid: [] for nid in node_ids} + + for e in edges: + src, dst = e["v"], e["w"] + if src in adj and dst in indeg: + adj[src].append(dst) + indeg[dst] += 1 + + q = deque([nid for nid in node_ids if indeg[nid] == 0]) + topo_index: dict[str, int] = {} + idx = 0 + + while q: + cur = q.popleft() + topo_index[cur] = idx + idx += 1 + for nxt in adj[cur]: + indeg[nxt] -= 1 + if indeg[nxt] == 0: + q.append(nxt) + + # Fallback for unexpected cycles: keep deterministic order. + for nid in node_ids: + if nid not in topo_index: + topo_index[nid] = idx + idx += 1 + + return topo_index + + +def _build_color_by_type_extension(nodes: list[dict[str, Any]]) -> GraphExtension: + ext = GraphExtension(id="color_by_type", name="Color By Type") + + for n in nodes: + info = n.get("info", {}) + ext.add_node_data( + n["id"], + { + "target": str(info.get("target", "unknown")), + "op": str(info.get("op", "unknown")), + "color_data": str(info.get("target") if info.get("op") == "call_function" else info.get("op")) + + }, + ) + + ext.set_label_formatter(lambda d: [f"color_data: {d.get('color_data', 'unknown')}"]) + ext.set_color_rule(CategoricalColorRule(attribute="color_data")) + return ext + + +def _build_topology_extension( + nodes: list[dict[str, Any]], + edges: list[dict[str, Any]], +) -> GraphExtension: + topo_idx = _compute_topological_index(nodes, edges) + + ext = GraphExtension(id="topological_order", name="Topological Order") + for n in nodes: + idx = topo_idx[n["id"]] + ext.add_node_data(n["id"], {"topo_index": idx}) + + ext.set_label_formatter(lambda d: [f"topo: {d.get('topo_index', -1)}"]) + ext.set_tooltip_formatter( + lambda d: [ + f"Topological index: {d.get('topo_index', -1)}", + ] + ) + ext.set_color_rule(NumericColorRule(attribute="topo_index", cmap="viridis", handle_outliers=False)) + return ext + + +def _export_with_extensions(model: torch.nn.Module, inputs: tuple[Any, ...], output_html: Path) -> None: + try: + ep_model = torch.export.export(model, inputs, strict=False) + ep_model = ep_model.run_decompositions() + graph_module = ep_model.graph_module + except Exception: + graph_module = torch.fx.symbolic_trace(model) + + exporter = FXGraphExporter(graph_module) + + # override base behavior + exporter.set_base_label_formatter(_base_label) + + base_payload = exporter.generate_json_payload() + base_nodes = base_payload["base"]["nodes"] + base_edges = base_payload["base"]["edges"] + + exporter.add_extension(_build_color_by_type_extension(base_nodes)) + exporter.add_extension(_build_topology_extension(base_nodes, base_edges)) + + exporter.export_html(str(output_html)) + + +def _build_swin_model() -> tuple[torch.nn.Module, tuple[Any, ...]]: + from transformers import SwinConfig, SwinForImageClassification + + config = SwinConfig( + image_size=224, + patch_size=4, + num_channels=3, + embed_dim=96, + depths=[2, 2, 2, 2], + num_heads=[3, 6, 12, 24], + window_size=7, + num_labels=10, + ) + model = SwinForImageClassification(config).eval().to("cpu") + inputs = (torch.rand(1, 3, 224, 224),) + return model, inputs + + +def _build_llama_model() -> tuple[torch.nn.Module, tuple[Any, ...]]: + from transformers import LlamaConfig, LlamaForCausalLM + + config = LlamaConfig( + vocab_size=128, + hidden_size=128, + intermediate_size=256, + num_hidden_layers=6, + num_attention_heads=8, + num_key_value_heads=8, + max_position_embeddings=256, + ) + model = LlamaForCausalLM(config).eval().to("cpu") + input_ids = torch.randint(0, config.vocab_size, (1, 32)) + inputs = (input_ids,) + return model, inputs + + +def main() -> None: + parser = argparse.ArgumentParser(description="FX Viewer V3 extension demo") + parser.add_argument( + "--model", + choices=["swin", "llama", "both"], + default="both", + help="Which model demo to export", + ) + parser.add_argument( + "--out-dir", + default=".", + help="Output directory for generated HTML files", + ) + args = parser.parse_args() + + out_dir = Path(args.out_dir).resolve() + out_dir.mkdir(parents=True, exist_ok=True) + + if args.model in ("swin", "both"): + print("Building Swin demo model...") + model, inputs = _build_swin_model() + out_file = out_dir / "swin_graph_v3_extensions.html" + _export_with_extensions(model, inputs, out_file) + print(f"Exported: {out_file}") + + if args.model in ("llama", "both"): + print("Building Llama demo model...") + model, inputs = _build_llama_model() + out_file = out_dir / "llama_graph_v3_extensions.html" + _export_with_extensions(model, inputs, out_file) + print(f"Exported: {out_file}") + + +if __name__ == "__main__": + main() diff --git a/backends/qualcomm/utils/fx_viewer/exporter.py b/backends/qualcomm/utils/fx_viewer/exporter.py new file mode 100644 index 00000000000..fe15ab04d2d --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/exporter.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import json +import os +import warnings +from dataclasses import asdict +from typing import Callable, List, Optional, Any, Dict + +import networkx as nx +import torch +import torch.fx + +from .color_rules import ColorRule +from .extension import GraphExtension +from .grandalf.layouts import SugiyamaLayout +from .grandalf.routing import route_with_lines +from .grandalf.utils.nx import convert_nextworkx_graph_to_grandalf +from .models import BaseGraphPayload, GraphEdge, GraphNode, GraphPayload + + +class FXGraphExporter: + """Export PyTorch FX graphs to JSON/JS/HTML payloads for the viewer.""" + + def __init__(self, graph_module: torch.fx.GraphModule): + self.graph_module = graph_module + self.extensions: List[GraphExtension] = [] + + self.base_label_formatter: Callable[[GraphNode], str] = self._default_base_label + self.base_tooltip_formatter: Callable[[GraphNode], List[str]] = self._default_base_tooltip + self.base_color_rule: Optional[ColorRule] = None + + def _default_base_label(self, node: GraphNode) -> str: + target = str(node.info.get("target") or node.info.get("op") or "") + return target.replace("aten.", "").replace(".default", "") + + def _default_base_tooltip(self, node: GraphNode) -> List[str]: + lines = [ + f"Name: {node.info.get('name', 'n/a')}", + f"Op: {node.info.get('op', 'n/a')}", + f"Target: {node.info.get('target', 'n/a')}", + ] + return lines + + def set_base_label_formatter(self, formatter: Callable[[GraphNode], str]): + self.base_label_formatter = formatter + + def set_base_tooltip_formatter(self, formatter: Callable[[GraphNode], List[str]]): + self.base_tooltip_formatter = formatter + + def set_base_color_rule(self, rule: ColorRule): + self.base_color_rule = rule + + def add_extension(self, extension: GraphExtension): + if not isinstance(extension, GraphExtension): + raise TypeError("extension must be a GraphExtension") + if any(ext.id == extension.id for ext in self.extensions): + raise ValueError(f"duplicate extension id: '{extension.id}'") + self.extensions.append(extension) + + @staticmethod + def _format_arg(arg): + if isinstance(arg, torch.fx.Node): + return arg.name + if isinstance(arg, (list, tuple)): + return type(arg)(FXGraphExporter._format_arg(a) for a in arg) + if isinstance(arg, dict): + return {k: FXGraphExporter._format_arg(v) for k, v in arg.items()} + return str(arg) + + def _extract_graph(self) -> tuple[dict[str, GraphNode], list[GraphEdge]]: + print("Building graph payload model...") + nodes: dict[str, GraphNode] = {} + edges: list[GraphEdge] = [] + + for fx_node in self.graph_module.graph.nodes: + info: dict[str, Any] = { + "op": fx_node.op, + "name": fx_node.name, + "target": str(fx_node.target), + "args": self._format_arg(fx_node.args), + "kwargs": self._format_arg(fx_node.kwargs), + } + + if "tensor_meta" in fx_node.meta: + tm = fx_node.meta["tensor_meta"] + if isinstance(tm, list): + info["tensor_shape"] = [tuple(t.shape) if hasattr(t, "shape") else None for t in tm] + info["dtype"] = [str(t.dtype) if hasattr(t, "dtype") else None for t in tm] + elif hasattr(tm, "shape"): + info["tensor_shape"] = tuple(tm.shape) + info["dtype"] = str(tm.dtype) if hasattr(tm, "dtype") else None + + for key, value in fx_node.meta.items(): + if key != "tensor_meta" and isinstance(value, (str, int, float, bool)): + info[key] = value + + nodes[fx_node.name] = GraphNode(id=fx_node.name, info=info) + + for input_node in fx_node.all_input_nodes: + edges.append(GraphEdge(v=input_node.name, w=fx_node.name)) + + return nodes, edges + + @staticmethod + def _validate_str_list(value: Any, *, context: str) -> list[str]: + if not isinstance(value, list) or any(not isinstance(x, str) for x in value): + warnings.warn(f"{context} must return list[str]", RuntimeWarning, stacklevel=2) + return [] + return value + + def _safe_base_label(self, node: GraphNode) -> str: + label = self.base_label_formatter(node) + if not isinstance(label, str): + warnings.warn( + f"base_label_formatter returned non-str for node '{node.id}', coercing to str", + RuntimeWarning, + stacklevel=2, + ) + return str(label) + return label + + def _safe_base_tooltip(self, node: GraphNode) -> list[str]: + try: + value = self.base_tooltip_formatter(node) + except Exception as exc: + warnings.warn( + f"base_tooltip_formatter failed for node '{node.id}': {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + return self._validate_str_list(value, context=f"base_tooltip_formatter(node='{node.id}')") + + def _ext_label_lines_for_layout(self, extension: GraphExtension, node_id: str) -> list[str]: + if not extension.label_formatter or node_id not in extension.nodes_data: + return [] + try: + result = extension.label_formatter(extension.nodes_data[node_id]) + except Exception as exc: + warnings.warn( + f"Extension '{extension.id}' label formatter failed for node '{node_id}' during layout: {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + return self._validate_str_list( + result, + context=f"extension '{extension.id}' label formatter(node='{node_id}')", + ) + + def _compute_layout(self, nodes: dict[str, GraphNode], edges: list[GraphEdge]) -> None: + print("Converting to Grandalf graph and computing layout...") + graph_nx = nx.DiGraph() + for node_id in nodes: + graph_nx.add_node(node_id) + for edge in edges: + graph_nx.add_edge(edge.v, edge.w) + + g_grandalf = convert_nextworkx_graph_to_grandalf(graph_nx) + + edge_map = {(edge.v, edge.w): edge for edge in edges} + + class NodeView: + def __init__(self, w, h): + self.w = w + self.h = h + self.xy = (0, 0) + + class EdgeView: + def __init__(self): + self.points = [] + + def setpath(self, points): + self.points = points + + for vertex in g_grandalf.V(): + node = nodes[vertex.data] + + base_label = self._safe_base_label(node) + max_char_width = len(base_label) + total_lines = 1 + + for ext in self.extensions: + ext_lines = self._ext_label_lines_for_layout(ext, node.id) + for line in ext_lines: + max_char_width = max(max_char_width, len(line)) + total_lines += 1 + + node.width = max(max_char_width * 7 + 20, 100) + node.height = total_lines * 16 + 20 + vertex.view = NodeView(node.width, node.height) + + for edge in g_grandalf.E(): + edge.view = EdgeView() + + print("Running Sugiyama Layout...") + for component in g_grandalf.C: + sug = SugiyamaLayout(component) + sug.route_edge = route_with_lines + sug.xspace = 20 + sug.yspace = 40 + sug.init_all(optimize=True) + sug.draw(N=5.5) + + for vertex in g_grandalf.V(): + node = nodes[vertex.data] + node.x = float(vertex.view.xy[0]) + node.y = float(vertex.view.xy[1]) + + for edge in g_grandalf.E(): + key = (edge.v[0].data, edge.v[1].data) + if key not in edge_map: + continue + points = [] + if hasattr(edge, "view") and hasattr(edge.view, "points"): + points = [{"x": float(p[0]), "y": float(p[1])} for p in edge.view.points] + edge_map[key].points = points + + def _build_base_payload(self, nodes: dict[str, GraphNode], edges: list[GraphEdge]) -> BaseGraphPayload: + print("Compiling base graph payload...") + + base_color_input = {node_id: node.info for node_id, node in nodes.items()} + base_colors: dict[str, str] = {} + base_legend: list[dict[str, str]] = [] + if self.base_color_rule: + base_colors, base_legend = self.base_color_rule.apply(base_color_input) + + for node in nodes.values(): + node.label = self._safe_base_label(node) + node.tooltip = self._safe_base_tooltip(node) + if node.id in base_colors: + node.fill_color = base_colors[node.id] + + return BaseGraphPayload( + legend=base_legend, + nodes=list(nodes.values()), + edges=edges, + ) + + def _build_extensions_payload(self) -> dict[str, Any]: + print("Compiling extension payloads...") + return {ext.id: ext.build() for ext in self.extensions} + + def generate_json_payload(self) -> Dict[str, Any]: + nodes, edges = self._extract_graph() + self._compute_layout(nodes, edges) + base_payload = self._build_base_payload(nodes, edges) + extensions_payload = self._build_extensions_payload() + payload = GraphPayload(base=base_payload, extensions=extensions_payload) + return asdict(payload) + + def export_json(self, output_path: str): + data = self.generate_json_payload() + with open(output_path, "w") as f: + json.dump(data, f, indent=2) + print(f"Success! Exported JSON payload to {output_path}") + + @staticmethod + def _load_viewer_js_bundle() -> str: + template_dir = os.path.join(os.path.dirname(__file__), "templates") + ordered_files = [ + "themes.js", + "graph_data_store.js", + "search_engine.js", + "view_controller.js", + "canvas_renderer.js", + "minimap_renderer.js", + "ui_manager.js", + "fx_graph_viewer.js", + ] + chunks = [] + for filename in ordered_files: + path = os.path.join(template_dir, filename) + with open(path, "r") as f: + chunks.append(f"\n// ---- {filename} ----\n") + chunks.append(f.read()) + return "\n".join(chunks) + + def export_js(self, container_id: str) -> str: + data = self.generate_json_payload() + json_str = json.dumps(data) + js_content = self._load_viewer_js_bundle() + + return f""" + const graphPayload = {json_str}; + + {js_content} + + (function() {{ + try {{ + const viewer = new FXGraphViewer('{container_id}', graphPayload); + viewer.init(); + window.fxViewer = viewer; + }} catch (e) {{ + console.error("Failed to mount FXGraphViewer:", e); + const container = document.getElementById('{container_id}'); + if (container) {{ + container.innerHTML = "
Error mounting graph: " + e.message + "
"; + }} + }} + }})(); + """ + + def export_html(self, output_html: str = "model_graph.html"): + data = self.generate_json_payload() + json_str = json.dumps(data) + js_content = self._load_viewer_js_bundle() + + html_content = f""" + + + + PyTorch FX Graph Viewer V3 + + + +
+
+
Loading Graph Viewer...
+
+
+ + + + + + +""" + + print(f"Writing to {output_html}...") + with open(output_html, "w") as f: + f.write(html_content) + + print(f"Success! Exported extensible graph to {output_html}") diff --git a/backends/qualcomm/utils/fx_viewer/extension.py b/backends/qualcomm/utils/fx_viewer/extension.py new file mode 100644 index 00000000000..c6aabfdea01 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/extension.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import Dict, Any, Callable, Optional +import warnings + +from .color_rules import ColorRule + + +class GraphExtension: + """Optional annotation layer attached to the base FX graph.""" + + def __init__(self, id: str, name: str): + clean_id = id.strip() + if not clean_id: + raise ValueError("GraphExtension id must be non-empty") + if not name.strip(): + raise ValueError("GraphExtension name must be non-empty") + + self.id = clean_id + self.name = name + self.nodes_data: Dict[str, Dict[str, Any]] = {} + + self.color_rule: Optional[ColorRule] = None + self.label_formatter: Optional[Callable[[Dict[str, Any]], list[str]]] = None + self.tooltip_formatter: Optional[Callable[[Dict[str, Any]], list[str]]] = None + + def add_node_data(self, node_id: str, data: Dict[str, Any]): + if node_id not in self.nodes_data: + self.nodes_data[node_id] = {} + self.nodes_data[node_id].update(data) + + def set_color_rule(self, rule: ColorRule): + self.color_rule = rule + + def set_label_formatter(self, formatter: Callable[[Dict[str, Any]], list[str]]): + self.label_formatter = formatter + + def set_tooltip_formatter(self, formatter: Callable[[Dict[str, Any]], list[str]]): + self.tooltip_formatter = formatter + + def _format_lines( + self, + *, + formatter: Callable[[Dict[str, Any]], list[str]], + data: Dict[str, Any], + node_id: str, + kind: str, + ) -> list[str]: + try: + result = formatter(data) + except Exception as exc: + warnings.warn( + f"Extension '{self.id}' {kind} formatter failed for node '{node_id}': {exc}", + RuntimeWarning, + stacklevel=2, + ) + return [] + + if not isinstance(result, list) or any(not isinstance(x, str) for x in result): + warnings.warn( + f"Extension '{self.id}' {kind} formatter must return list[str] for node '{node_id}'", + RuntimeWarning, + stacklevel=2, + ) + return [] + + return result + + def build(self) -> Dict[str, Any]: + node_colors = {} + legend = [] + + if self.color_rule: + node_colors, legend = self.color_rule.apply(self.nodes_data) + + compiled_nodes = {} + + for node_id, data in self.nodes_data.items(): + compiled = {"info": data} + + if self.label_formatter: + lines = self._format_lines( + formatter=self.label_formatter, + data=data, + node_id=node_id, + kind="label", + ) + if lines: + compiled["label_append"] = lines + + if self.tooltip_formatter: + lines = self._format_lines( + formatter=self.tooltip_formatter, + data=data, + node_id=node_id, + kind="tooltip", + ) + if lines: + compiled["tooltip"] = lines + + if node_id in node_colors: + compiled["fill_color"] = node_colors[node_id] + + compiled_nodes[node_id] = compiled + + return { + "name": self.name, + "legend": legend, + "nodes": compiled_nodes, + } diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/LICENSE b/backends/qualcomm/utils/fx_viewer/grandalf/LICENSE new file mode 100644 index 00000000000..cb1ede01c54 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/LICENSE @@ -0,0 +1,564 @@ +Grandalf is distributed under either one of the two licenses listed below, +namely : + + - the GNU GENERAL PUBLIC LICENSE Version 2 +or + - the Eclise Public License -v 1.0 + + +-------------------------------------------------------------------------------- + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) 19yy + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) 19yy name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. + +-------------------------------------------------------------------------------- + +Eclipse Public License -v 1.0 + +THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC +LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM +CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. + +1. DEFINITIONS + +"Contribution" means: + +a) in the case of the initial Contributor, the initial code and documentation +distributed under this Agreement, and + +b) in the case of each subsequent Contributor: + +i) changes to the Program, and + +ii) additions to the Program; + +where such changes and/or additions to the Program originate from and are +distributed by that particular Contributor. A Contribution 'originates' from a +Contributor if it was added to the Program by such Contributor itself or anyone +acting on such Contributor's behalf. Contributions do not include additions to +the Program which: (i) are separate modules of software distributed in +conjunction with the Program under their own license agreement, and (ii) are not +derivative works of the Program. + +"Contributor" means any person or entity that distributes the Program. + +"Licensed Patents " mean patent claims licensable by a Contributor which are +necessarily infringed by the use or sale of its Contribution alone or when +combined with the Program. + +"Program" means the Contributions distributed in accordance with this Agreement. + +"Recipient" means anyone who receives the Program under this Agreement, +including all Contributors. + +2. GRANT OF RIGHTS + +a) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free copyright license to +reproduce, prepare derivative works of, publicly display, publicly perform, +distribute and sublicense the Contribution of such Contributor, if any, and such +derivative works, in source code and object code form. + +b) Subject to the terms of this Agreement, each Contributor hereby grants +Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed +Patents to make, use, sell, offer to sell, import and otherwise transfer the +Contribution of such Contributor, if any, in source code and object code form. +This patent license shall apply to the combination of the Contribution and the +Program if, at the time the Contribution is added by the Contributor, such +addition of the Contribution causes such combination to be covered by the +Licensed Patents. The patent license shall not apply to any other combinations +which include the Contribution. No hardware per se is licensed hereunder. + +c) Recipient understands that although each Contributor grants the licenses to +its Contributions set forth herein, no assurances are provided by any +Contributor that the Program does not infringe the patent or other intellectual +property rights of any other entity. Each Contributor disclaims any liability to +Recipient for claims brought by any other entity based on infringement of +intellectual property rights or otherwise. As a condition to exercising the +rights and licenses granted hereunder, each Recipient hereby assumes sole +responsibility to secure any other intellectual property rights needed, if any. +For example, if a third party patent license is required to allow Recipient to +distribute the Program, it is Recipient's responsibility to acquire that license +before distributing the Program. + +d) Each Contributor represents that to its knowledge it has sufficient copyright +rights in its Contribution, if any, to grant the copyright license set forth in +this Agreement. + +3. REQUIREMENTS + +A Contributor may choose to distribute the Program in object code form under its +own license agreement, provided that: + +a) it complies with the terms and conditions of this Agreement; and + +b) its license agreement: + +i) effectively disclaims on behalf of all Contributors all warranties and +conditions, express and implied, including warranties or conditions of title and +non-infringement, and implied warranties or conditions of merchantability and +fitness for a particular purpose; + +ii) effectively excludes on behalf of all Contributors all liability for +damages, including direct, indirect, special, incidental and consequential +damages, such as lost profits; + +iii) states that any provisions which differ from this Agreement are offered by +that Contributor alone and not by any other party; and + +iv) states that source code for the Program is available from such Contributor, +and informs licensees how to obtain it in a reasonable manner on or through a +medium customarily used for software exchange. + +When the Program is made available in source code form: + +a) it must be made available under this Agreement; and + +b) a copy of this Agreement must be included with each copy of the Program. + +Contributors may not remove or alter any copyright notices contained within the +Program. + +Each Contributor must identify itself as the originator of its Contribution, if +any, in a manner that reasonably allows subsequent Recipients to identify the +originator of the Contribution. + +4. COMMERCIAL DISTRIBUTION + +Commercial distributors of software may accept certain responsibilities with +respect to end users, business partners and the like. While this license is +intended to facilitate the commercial use of the Program, the Contributor who +includes the Program in a commercial product offering should do so in a manner +which does not create potential liability for other Contributors. Therefore, if +a Contributor includes the Program in a commercial product offering, such +Contributor ("Commercial Contributor") hereby agrees to defend and indemnify +every other Contributor ("Indemnified Contributor") against any losses, damages +and costs (collectively "Losses") arising from claims, lawsuits and other legal +actions brought by a third party against the Indemnified Contributor to the +extent caused by the acts or omissions of such Commercial Contributor in +connection with its distribution of the Program in a commercial product +offering. The obligations in this section do not apply to any claims or Losses +relating to any actual or alleged intellectual property infringement. In order +to qualify, an Indemnified Contributor must: a) promptly notify the Commercial +Contributor in writing of such claim, and b) allow the Commercial Contributor to +control, and cooperate with the Commercial Contributor in, the defense and any +related settlement negotiations. The Indemnified Contributor may participate in +any such claim at its own expense. + +For example, a Contributor might include the Program in a commercial product +offering, Product X. That Contributor is then a Commercial Contributor. If that +Commercial Contributor then makes performance claims, or offers warranties +related to Product X, those performance claims and warranties are such +Commercial Contributor's responsibility alone. Under this section, the +Commercial Contributor would have to defend claims against the other +Contributors related to those performance claims and warranties, and if a court +requires any other Contributor to pay any damages as a result, the Commercial +Contributor must pay those damages. + +5. NO WARRANTY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR +IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each +Recipient is solely responsible for determining the appropriateness of using and +distributing the Program and assumes all risks associated with its exercise of +rights under this Agreement , including but not limited to the risks and costs +of program errors, compliance with applicable laws, damage to or loss of data, +programs or equipment, and unavailability or interruption of operations. + +6. DISCLAIMER OF LIABILITY + +EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY +CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST + PROFITS), HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR +DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +7. GENERAL + +If any provision of this Agreement is invalid or unenforceable under applicable +law, it shall not affect the validity or enforceability of the remainder of the +terms of this Agreement, and without further action by the parties hereto, such +provision shall be reformed to the minimum extent necessary to make such +provision valid and enforceable. + +If Recipient institutes patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Program itself +(excluding combinations of the Program with other software or hardware) +infringes such Recipient's patent(s), then such Recipient's rights granted under +Section 2(b) shall terminate as of the date such litigation is filed. + +All Recipient's rights under this Agreement shall terminate if it fails to +comply with any of the material terms or conditions of this Agreement and does +not cure such failure in a reasonable period of time after becoming aware of +such noncompliance. If all Recipient's rights under this Agreement terminate, +Recipient agrees to cease use and distribution of the Program as soon as +reasonably practicable. However, Recipient's obligations under this Agreement +and any licenses granted by Recipient relating to the Program shall continue and +survive. + +Everyone is permitted to copy and distribute copies of this Agreement, but in +order to avoid inconsistency the Agreement is copyrighted and may only be +modified in the following manner. The Agreement Steward reserves the right to +publish new versions (including revisions) of this Agreement from time to time. +No one other than the Agreement Steward has the right to modify this Agreement. +The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation +may assign the responsibility to serve as the Agreement Steward to a suitable +separate entity. Each new version of the Agreement will be given a +distinguishing version number. The Program (including Contributions) may always +be distributed subject to the version of the Agreement under which it was +received. In addition, after a new version of the Agreement is published, +Contributor may elect to distribute the Program (including its Contributions) +under the new version. Except as expressly stated in Sections 2(a) and 2(b) +above, Recipient receives no rights or licenses to the intellectual property of +any Contributor under this Agreement, whether expressly, by implication, +estoppel or otherwise. All rights in the Program not expressly granted under +this Agreement are reserved. + +This Agreement is governed by the laws of the State of New York and the +intellectual property laws of the United States of America. No party to this +Agreement will bring a legal action under this Agreement more than one year +after the cause of action arose. Each party waives its rights to a jury trial in +any resulting litigation. diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/__init__.py b/backends/qualcomm/utils/fx_viewer/grandalf/__init__.py new file mode 100644 index 00000000000..65c98baf91d --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/__init__.py @@ -0,0 +1 @@ +__all__ = ["graphs", "layouts", "routing", "utils"] diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/graphs.py b/backends/qualcomm/utils/fx_viewer/grandalf/graphs.py new file mode 100644 index 00000000000..179d0e88ff2 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/graphs.py @@ -0,0 +1,882 @@ +# -*- coding: utf-8 -*- + +""" +.. _graphs: + +graphs.py +========= +This module implements essential graph classes for representing +vertices (nodes), edges (links), and graphs. + +""" + +# This code is part of Grandalf +# Copyright (C) 2008-2011 Axel Tillequin (bdcht3@gmail.com) +# published under GPLv2 license or EPLv1 license + +from .utils import Poset + +# ------------------------------------------------------------------------------ + + +class vertex_core(object): + """ The Vertex essentials attributes and methods. + + Attributes: + e (list[Edge]): list of edges associated with this vertex. + + Methods: + deg() : degree of the vertex (number of edges). + e_in() : list of edges directed toward this vertex. + e_out(): list of edges directed outward this vertex. + e_dir(int): either e_in, e_out or all edges depending on + provided direction parameter (>0 means outward). + N(f_io=0): list of neighbor vertices in all directions (default) + or in filtered f_io direction (>0 means outward). + e_to(v): returns the Edge from this vertex directed toward vertex v. + e_from(v): returns the Edge from vertex v directed toward this vertex. + e_with(v): return the Edge with both this vertex and vertex v + detach(): removes this vertex from all its edges and returns this list + of edges. + """ + + def __init__(self): + # will hold list of edges for this vertex (adjacency list) + self.e = [] + + def deg(self): + return len(self.e) + + def e_in(self): + return list(filter((lambda e: e.v[1] == self), self.e)) + + def e_out(self): + return list(filter((lambda e: e.v[0] == self), self.e)) + + def e_dir(self, dir): + if dir > 0: + return self.e_out() + if dir < 0: + return self.e_in() + return self.e + + def N(self, f_io=0): + N = [] + if f_io <= 0: + N += [e.v[0] for e in self.e_in()] + if f_io >= 0: + N += [e.v[1] for e in self.e_out()] + return N + + def e_to(self, y): + for e in self.e_out(): + if e.v[1] == y: + return e + return None + + def e_from(self, x): + for e in self.e_in(): + if e.v[0] == x: + return e + return None + + def e_with(self, v): + for e in self.e: + if v in e.v: + return e + return None + + def detach(self): + E = self.e[:] + for e in E: + e.detach() + assert self.deg() == 0 + return E + + +# ------------------------------------------------------------------------------ + + +class edge_core(object): + """The Edge essentials attributes. + + Attributes: + v (list[Vertex]): list of vertices associated with this edge. + deg (int): degree of the edge (number of unique vertices). + """ + + def __init__(self, x, y): + self.deg = 0 if x == y else 1 + self.v = (x, y) + + +# ------------------------------------------------------------------------------ + + +class Vertex(vertex_core): + """Vertex class enhancing a vertex_core with graph-related features. + + Attributes: + c (graph_core): the component of connected vertices that contains this vertex. + By default a vertex belongs no component but when it is added in a + graph, c points to the connected component in this graph. + data (object) : an object associated with the vertex. + """ + + def __init__(self, data=None): + super().__init__() + # by default, a new vertex belongs to its own component + # but when the vertex is added to a graph, c points to the + # connected component where it belongs. + self.c = None + self.data = data + self.__index = None + + @property + def index(self): + if self.__index: + return self.__index + elif isinstance(self.c, graph_core): + self.__index = self.c.sV.index(self) + return self.__index + else: + return None + + def __lt__(self, v): + return 0 + + def __gt__(self, v): + return 0 + + def __le__(self, v): + return 0 + + def __ge__(self, v): + return 0 + + def __getstate__(self): + return (self.index, self.data) + + def __setstate__(self, state): + self.__index, self.data = state + self.c = None + self.e = [] + + +# ------------------------------------------------------------------------------ + + +class Edge(edge_core): + """Edge class enhancing edge_core with attributes and methods related to the graph. + + Attributes: + w (int): a weight associated with the edge (default 1) used by Dijkstra to + find min-flow paths. + data (object): an object associated with the edge. + feedback (bool): indicates if the edge has been marked as a *feeback* edge + by the Tarjan algorithm which means that it is part of a cycle and that + inverting this edge would remove this cycle. + + Methods: + attach(): add this edge in its vertices edge lists. + detach(): remove this edge from its vertices edge lists. + """ + + def __init__(self, x, y, w=1, data=None, connect=False): + super().__init__(x, y) + # w is an optional weight associated with the edge. + self.w = w + self.data = data + self.feedback = False + if connect and (x.c is None or y.c is None): + c = x.c or y.c + c.add_edge(self) + + def attach(self): + if not self in self.v[0].e: + self.v[0].e.append(self) + if not self in self.v[1].e: + self.v[1].e.append(self) + + def detach(self): + if self.deg == 1: + assert self in self.v[0].e + assert self in self.v[1].e + self.v[0].e.remove(self) + self.v[1].e.remove(self) + else: + if self in self.v[0].e: + self.v[0].e.remove(self) + assert self not in self.v[0].e + return [self] + + def __lt__(self, v): + return 0 + + def __gt__(self, v): + return 0 + + def __le__(self, v): + return 0 + + def __ge__(self, v): + return 0 + + def __getstate__(self): + xi, yi = (self.v[0].index, self.v[1].index) + return (xi, yi, self.w, self.data, self.feedback) + + def __setstate__(self, state): + xi, yi, self.w, self.data, self.feedback = state + self._v = [xi, yi] + self.deg = 0 if xi == yi else 1 + + +# ------------------------------------------------------------------------------ + + +class graph_core(object): + """A connected graph of Vertex/Edge objects. A graph_core is a *component* + of a Graph that contains a connected set of Vertex and Edges. + + Attributes: + sV (poset[Vertex]): the partially ordered set of vertices of the graph. + sE (poset[Edge]): the partially ordered set of edges of the graph. + degenerated_edges (set[Edge]): the set of *degenerated* edges (of degree 0). + directed (bool): indicates if the graph is considered *oriented* or not. + + Methods: + V(cond=None): generates an iterator over vertices, with optional filter + E(cond=None): generates an iterator over edges, with optional filter + M(cond=None): returns the associativity matrix of the graph component + order(): the order of the graph (number of vertices) + norm(): the norm of the graph (number of edges) + deg_min(): the minimum degree of vertices + deg_max(): the maximum degree of vertices + deg_avg(): the average degree of vertices + eps(): the graph epsilon value (norm/order), average number of edges per vertex. + path(x,y,f_io=0,hook=None): shortest path between vertices x and y by breadth-first descent, + contrained by f_io direction if provided. The path is returned as a list of Vertex objects. + If a *hook* function is provided, it is called at every vertex added to the path, passing + the vertex object as argument. + roots(): returns the list of *roots* (vertices with no inward edges). + leaves(): returns the list of *leaves* (vertices with no outward edges). + add_single_vertex(v): allow a graph_core to hold a single vertex. + add_edge(e): add edge e. At least one of its vertex must belong to the graph, + the other being added automatically. + remove_edge(e): remove Edge e, asserting that the resulting graph is still connex. + remove_vertex(x): remove Vertex x and all associated edges. + dijkstra(x,f_io=0,hook=None): shortest weighted-edges paths between x and all other vertices + by dijkstra's algorithm with heap used as priority queue. + get_scs_with_feedback(): returns the set of strongly connected components + ("scs") by using Tarjan algorithm. + These are maximal sets of vertices such that there is a path from each + vertex to every other vertex. + The algorithm performs a DFS from the provided list of root vertices. + A cycle is of course a strongly connected component, + but a strongly connected component can include several cycles. + The Feedback Acyclic Set of edge to be removed/reversed is provided by + marking the edges with a "feedback" flag. + Complexity is O(V+E). + partition(): returns a *partition* of the connected graph as a list of lists. + N(v): returns neighbours of a vertex v. + """ + + def __init__(self, V=None, E=None, directed=True): + if V is None: + V = [] + if E is None: + E = [] + self.directed = directed + self.sV = Poset(V) + self.sE = Poset([]) + + self.degenerated_edges = set() + + if len(self.sV) == 1: + v = self.sV[0] + v.c = self + for e in v.e: + e.detach() + return + + for e in E: + x = self.sV.get(e.v[0]) + y = self.sV.get(e.v[1]) + if x is None or y is None: + raise ValueError("unknown Vertex (%s or %s)" % e.v) + e.v = (x, y) + if e.deg == 0: + self.degenerated_edges.add(e) + e = self.sE.add(e) + e.attach() + if x.c is None: + x.c = Poset([x]) + if y.c is None: + y.c = Poset([y]) + if id(x.c) != id(y.c): + x, y = (x, y) if len(x.c) > len(y.c) else (y, x) + x.c.update(y.c) + for v in y.c: + v.c = x.c + s = x.c + # check if graph is connected: + for v in self.V(): + if v.c is None or (v.c != s): + raise ValueError("unconnected Vertex %s" % v.data) + else: + v.c = self + + def roots(self): + return list(filter(lambda v: len(v.e_in()) == 0, self.sV)) + + def leaves(self): + return list(filter(lambda v: len(v.e_out()) == 0, self.sV)) + + def add_single_vertex(self, v): + if len(self.sE) == 0 and len(self.sV) == 0: + v = self.sV.add(v) + v.c = self + return v + return None + + def add_edge(self, e): + if e in self.sE: + return self.sE.get(e) + x = e.v[0] + y = e.v[1] + if not ((x in self.sV) or (y in self.sV)): + raise ValueError("unconnected edge") + x = self.sV.add(x) + y = self.sV.add(y) + e.v = (x, y) + e.attach() + e = self.sE.add(e) + x.c = self + y.c = self + if e.deg == 0: + self.degenerated_edges.add(e) + return e + + def remove_edge(self, e): + if not e in self.sE: + return + e.detach() + # check if still connected (path is not oriented here): + if e.deg == 1 and not self.path(e.v[0], e.v[1]): + # return to inital state by reconnecting everything: + e.attach() + # exit with exception! + raise ValueError(e) + else: + e = self.sE.remove(e) + if e in self.degenerated_edges: + self.degenerated_edges.remove(e) + return e + + def remove_vertex(self, x): + if x not in self.sV: + return + V = x.N() # get all neighbor vertices to check paths + E = x.detach() # remove the edges from x and neighbors list + # now we need to check if all neighbors are still connected, + # and it is sufficient to check if one of them is connected to + # all others: + v0 = V.pop(0) + for v in V: + if not self.path(v0, v): + # repair everything and raise exception if not connected: + for e in E: + e.attach() + raise ValueError(x) + # remove edges and vertex from internal sets: + for e in E: + self.sE.remove(e) + x = self.sV.remove(x) + x.c = None + return x + + def V(self, cond=None): + V = self.sV + if cond is None: + cond = lambda x: True + for v in V: + if cond(v): + yield v + + def E(self, cond=None): + E = self.sE + if cond is None: + cond = lambda x: True + for e in E: + if cond(e): + yield e + + def M(self, cond=None): + from array import array + + mat = [] + for v in self.V(cond): + vec = array("b", [0] * self.order()) + mat.append(vec) + for e in v.e_in(): + v0 = e.v[0] + if v0.index == v.index: + continue + vec[v0.index] = -e.w + for e in v.e_out(): + v1 = e.v[1] + vec[v1.index] = e.w + return mat + + def order(self): + return len(self.sV) + + def norm(self): + return len(self.sE) + + def deg_min(self): + return min([v.deg() for v in self.sV]) + + def deg_max(self): + return max([v.deg() for v in self.sV]) + + def deg_avg(self): + return sum([v.deg() for v in self.sV]) / float(self.order()) + + def eps(self): + return float(self.norm()) / self.order() + + def path(self, x, y, f_io=0, hook=None): + assert x in self.sV + assert y in self.sV + x = self.sV.get(x) + y = self.sV.get(y) + if x == y: + return [] + if f_io != 0: + assert self.directed == True + # path: + p = None + if hook is None: + hook = lambda x: False + # apply hook: + hook(x) + # visisted: + v = {x: None} + # queue: + q = [x] + while (not p) and len(q) > 0: + c = q.pop(0) + for n in c.N(f_io): + if not n in v: + hook(n) + v[n] = c + if n == y: + p = [n] + q.append(n) + if p: + break + # now we fill the path p backward from y to x: + while p and p[0] != x: + p.insert(0, v[p[0]]) + return p + + def dijkstra(self, x, f_io=0, hook=None, subset=None): + from collections import defaultdict + from heapq import heappop, heappush + + if x not in self.sV: + return None + if f_io != 0: + assert self.directed == True + # initiate with path to itself... + v = self.sV.get(x) + # D is the returned vector of distances: + D = defaultdict(lambda: None) + D[v] = 0.0 + L = [(D[v], v)] + while len(L) > 0: + l, u = heappop(L) + for e in u.e_dir(f_io): + v = e.v[0] if (u is e.v[1]) else e.v[1] + if subset is not None: + if v not in subset: + continue + Dv = l + e.w + if D[v] != None: + # check if heap/D needs updating: + # ignore if a shorter path was found already... + if Dv < D[v]: + for i, t in enumerate(L): + if t[1] is v: + L.pop(i) + break + D[v] = Dv + heappush(L, (Dv, v)) + else: + D[v] = Dv + heappush(L, (Dv, v)) + return D + + def get_scs_with_feedback(self, roots=None): + from sys import getrecursionlimit, setrecursionlimit + + limit = getrecursionlimit() + N = self.norm() + 10 + if N > limit: + setrecursionlimit(N) + + def _visit(v, L): + v.ind = v.ncur + v.lowlink = v.ncur + Vertex.ncur += 1 + self.tstack.append(v) + v.mark = True + for e in v.e_out(): + w = e.v[1] + if w.ind == 0: + _visit(w, L) + v.lowlink = min(v.lowlink, w.lowlink) + elif w.mark: + e.feedback = True + if w in self.tstack: + v.lowlink = min(v.lowlink, w.ind) + if v.lowlink == v.ind: + l = [self.tstack.pop()] + while l[0] != v: + l.insert(0, self.tstack.pop()) + # print "unstacked %s"%('-'.join([x.data[1:13] for x in l])) + L.append(l) + v.mark = False + + if roots is None: + roots = self.roots() + self.tstack = [] + scs = [] + Vertex.ncur = 1 + for v in self.sV: + v.ind = 0 + # start exploring tree from roots: + for v in roots: + v = self.sV.get(v) + if v.ind == 0: + _visit(v, scs) + # now possibly unvisited vertices: + for v in self.sV: + if v.ind == 0: + _visit(v, scs) + # clean up Tarjan-specific data: + for v in self.sV: + del v.ind + del v.lowlink + del v.mark + del Vertex.ncur + del self.tstack + setrecursionlimit(limit) + return scs + + def partition(self): + V = self.sV.copy() + R = self.roots() + for r in R: + V.remove(r) + parts = [] + while len(R) > 0: + v = R.pop(0) + p = Poset([v]) + l = v.N(+1) + while len(l) > 0: + x = l.pop(0) + if x in p: + continue + if all([(y in p) for y in x.N(-1)]): + p.add(x) + if x in R: + R.remove(x) + else: + V.remove(x) + l.extend(x.N(+1)) + else: + if x in V: + V.remove(x) + R.append(x) + parts.append(list(p)) + return parts + + def N(self, v, f_io=0): + return v.N(f_io) + + # general graph properties: + # ------------------------- + + # returns True iff + # - o is a subgraph of self, or + # - o is a vertex in self, or + # - o is an edge in self + def __contains__(self, o): + try: + return o.sV.issubset(self.sV) and o.sE.issubset(self.sE) + except AttributeError: + return (o in self.sV) or (o in self.sE) + + # merge graph_core G into self + def union_update(self, G): + for v in G.sV: + v.c = self + self.sV.update(G.sV) + self.sE.update(G.sE) + + # derivated graphs: + # ----------------- + + # returns subgraph spanned by vertices V + def spans(self, V): + raise NotImplementedError + + # returns join of G (if disjoint) + def __mul__(self, G): + raise NotImplementedError + + # returns complement of a graph G + def complement(self, G): + raise NotImplementedError + + # contraction G\e + def contract(self, e): + raise NotImplementedError + + def __getstate__(self): + V = [v for v in self.sV] + E = [e for e in self.sE] + return (V, E, self.directed) + + def __setstate__(self, state): + V, E, directed = state + for e in E: + e.v = [V[x] for x in e._v] + del e._v + graph_core.__init__(self, V, E, directed) + + +# ------------------------------------------------------------------------------ + + +class Graph(object): + """Disjoint-set Graph. + The graph is stored in disjoint-sets holding each connex component + in self.C as a list of graph_core objects. + + Attributes: + C (list[graph_core]): list of graph_core components. + + Methods: + add_vertex(v): add vertex v into the Graph as a new component + add_edge(e): add edge e and its vertices into the Graph possibly merging the + associated graph_core components + get_vertices_count(): see order() + V(): see graph_core + E(): see graph_core + remove_edge(e): remove edge e possibly spawning two new cores + if the graph_core that contained e gets disconnected. + remove_vertex(v): remove vertex v and all its edges. + order(): the order of the graph (number of vertices) + norm(): the norm of the graph (number of edges) + deg_min(): the minimum degree of vertices + deg_max(): the maximum degree of vertices + deg_avg(): the average degree of vertices + eps(): the graph epsilon value (norm/order), average number of edges per vertex. + connected(): returns True if the graph is connected (i.e. it has only one component). + components(): returns self.C + """ + + component_class = graph_core + + def __init__(self, V=None, E=None, directed=True): + if V is None: + V = [] + if E is None: + E = [] + self.directed = directed + # tag connex set of vertices: + # at first, every vertex is its own component + for v in V: + v.c = Poset([v]) + CV = [v.c for v in V] + # then pass through edges and union associated vertices such that + # CV finally holds only connected sets: + for e in E: + x = e.v[0] + y = e.v[1] + assert x in V + assert y in V + assert x.c in CV + assert y.c in CV + e.attach() + if x.c != y.c: + # merge y.c into x.c : + x.c.update(y.c) + # update set list (MUST BE DONE BEFORE UPDATING REFS!) + CV.remove(y.c) + # update reference: + for z in y.c: + z.c = x.c + # now create edge sets from connected vertex sets and + # make the graph_core connected graphs for this component : + self.C = [] + for c in CV: + s = set() + for v in c: + s.update(v.e) + self.C.append(self.component_class(c, s, directed)) + + def add_vertex(self, v): + for c in self.C: + if v in c.sV: + return c.sV.get(v) + g = self.component_class(directed=self.directed) + v = g.add_single_vertex(v) + self.C.append(g) + return v + + def add_edge(self, e): + # take vertices: + x = e.v[0] + y = e.v[1] + x = self.add_vertex(x) + y = self.add_vertex(y) + # take respective graph_cores: + cx = x.c + cy = y.c + # add edge: + e = cy.add_edge(e) + # connect (union) the graphs: + if cx != cy: + cx.union_update(cy) + self.C.remove(cy) + return e + + def get_vertices_count(self): + return sum([c.order() for c in self.C]) + + def V(self): + for c in self.C: + V = c.sV + for v in V: + yield v + + def E(self): + for c in self.C: + E = c.sE + for e in E: + yield e + + def remove_edge(self, e): + # get the graph_core: + c = e.v[0].c + assert c == e.v[1].c + if not c in self.C: + return None + # remove edge in graph_core and replace it with two new cores + # if removing edge disconnects the graph_core: + try: + e = c.remove_edge(e) + except ValueError: + e = c.sE.remove(e) + e.detach() + self.C.remove(c) + tmpg = type(self)(c.sV, c.sE, self.directed) + assert len(tmpg.C) == 2 + self.C.extend(tmpg.C) + return e + + def remove_vertex(self, x): + # get the graph_core: + c = x.c + if not c in self.C: + return None + try: + x = c.remove_vertex(x) + if c.order() == 0: + self.C.remove(c) + except ValueError: + for e in x.detach(): + c.sE.remove(e) + x = c.sV.remove(x) + self.C.remove(c) + tmpg = type(self)(c.sV, c.sE, self.directed) + assert len(tmpg.C) == 2 + self.C.extend(tmpg.C) + return x + + def order(self): + return sum([c.order() for c in self.C]) + + def norm(self): + return sum([c.norm() for c in self.C]) + + def deg_min(self): + return min([c.deg_min() for c in self.C]) + + def deg_max(self): + return max([c.deg_max() for c in self.C]) + + def deg_avg(self): + t = 0.0 + for c in self.C: + t += sum([v.deg() for v in c.sV]) + return t / float(self.order()) + + def eps(self): + return float(self.norm()) / self.order() + + def path(self, x, y, f_io=0, hook=None): + if x == y: + return [] + if x.c != y.c: + return None + # path: + return x.c.path(x, y, f_io, hook) + + def N(self, v, f_io=0): + return v.N(f_io) + + def __contains__(self, G): + r = False + for c in self.C: + r |= G in c + return r + + def connected(self): + return len(self.C) == 1 + + # returns connectivity (kappa) + def connectivity(self): + raise NotImplementedError + + # returns edge-connectivity (lambda) + def e_connectivity(self): + raise NotImplementedError + + # returns the list of graphs components + def components(self): + return self.C + + # derivated graphs: + # ----------------- + + # returns subgraph spanned by vertices V + def spans(self, V): + raise NotImplementedError + + # returns join of G (if disjoint) + def __mul__(self, G): + raise NotImplementedError + + # returns complement of a graph G + def complement(self, G): + raise NotImplementedError + + # contraction G\e + def contract(self, e): + raise NotImplementedError diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/layouts.py b/backends/qualcomm/utils/fx_viewer/grandalf/layouts.py new file mode 100644 index 00000000000..5c532b2e04e --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/layouts.py @@ -0,0 +1,1095 @@ +# -*- coding: utf-8 -*- + +""" +.. _layouts: + +layouts.py +========== +Layouts are classes that provide graph drawing algorithms. + +These classes all take a :class:`graph_core` argument. The graph +topology will never be permanently modified by the drawing algorithm: +e.g. "dummy" node insertion, edge reversal for making the graph +acyclic and so on, are all kept inside the layout object. +""" + +# This code is part of Grandalf +# Copyright (C) 2010-2012 Axel Tillequin (bdcht3@gmail.com) +# published under the GPLv2 license or EPLv1 license + +import importlib +from bisect import bisect +from sys import getrecursionlimit, setrecursionlimit + +from .utils import * + +# ------------------------------------------------------------------------------ + + +class VertexViewer(object): + """ + The VertexViewer class is used as the default provider of + Vertex dimensions (w,h) and position (xy). + In most cases it should be replaced by *view* instances associated + with a ui widgets library, allowing to get dimensions and + set position directly on the widget. + """ + + def __init__(self, w=2, h=2, data=None): + self.w = w + self.h = h + self.data = data + self.xy = None + + def __str__(self, *args, **kwargs): + return "VertexViewer (xy: %s) w: %s h: %s" % (self.xy, self.w, self.h) + + +# ------------------------------------------------------------------------------ + + +class _sugiyama_vertex_attr(object): + """ + The sugiyama layout adds new attributes to vertices. + These attributes are stored in an internal _sugimyama_vertex_attr object. + + Attributes: + rank (int): the rank number is the index of the layer that + contains this vertex. + dummy (0/1): a flag indicating if the vertex is *dummy* + pos (int): the index of the vertex in the layer + x (list(float)): the list of computed horizontal coordinates of the vertex + bar (float): the current *barycenter* of the vertex + """ + + def __init__(self, r=None, d=0): + self.rank = r + self.dummy = d + self.pos = None + self.x = 0 + self.bar = None + + def __str__(self): + s = "(%3d,%3d) x=%s" % (self.rank, self.pos, str(self.x)) + if self.dummy: + s = "[d] %s" % s + return s + + # def __eq__(self,x): + # return self.bar == x.bar + # def __ne__(self,x): + # return self.bar != x.bar + # def __lt__(self,x): + # return self.bar < x.bar + # def __le__(self,x): + # return self.bar <= x.bar + # def __gt__(self,x): + # return self.bar > x.bar + # def __ge__(self,x): + # return self.bar >= x.bar + + +# ------------------------------------------------------------------------------ + + +class DummyVertex(_sugiyama_vertex_attr): + """ + The DummyVertex class is used by the sugiyama layout to represent + *long* edges, i.e. edges that span over several ranks. + For these edges, a DummyVertex is inserted in every inner layer. + + Attributes: + view (viewclass): since a DummyVertex is acting as a Vertex, it + must have a view. + ctrl (list[_sugiyama_attr]): the list of associated dummy vertices + + Methods: + N(dir): reflect the Vertex method and returns the list of adjacent + vertices (possibly dummy) in the given direction. + inner(dir): return True if a neighbor in the given direction is *dummy*. + """ + + def __init__(self, r=None, viewclass=VertexViewer): + self.view = viewclass() + self.ctrl = None + super().__init__(r, d=1) + + def N(self, dir): + assert dir == +1 or dir == -1 + v = self.ctrl.get(self.rank + dir, None) + return [v] if v is not None else [] + + def inner(self, dir): + assert dir == +1 or dir == -1 + try: + return any([x.dummy == 1 for x in self.N(dir)]) + except KeyError: + return False + except AttributeError: + return False + + def __str__(self): + s = "(%3d,%3d) x=%s" % (self.rank, self.pos, str(self.x)) + if self.dummy: + s = "[d] %s" % s + return s + + +# ------------------------------------------------------------------------------ + + +class Layer(list): + """ + Layer is where Sugiyama layout organises vertices in hierarchical lists. + The placement of a vertex is done by the Sugiyama class, but it highly relies on + the *ordering* of vertices in each layer to reduce crossings. + This ordering depends on the neighbors found in the upper or lower layers. + + Attributes: + layout (SugiyamaLayout): a reference to the sugiyama layout instance that + contains this layer + upper (Layer): a reference to the *upper* layer (rank-1) + lower (Layer): a reference to the *lower* layer (rank+1) + ccount (int) : number of crossings detected in this layer + + Methods: + setup (layout): set initial attributes values from provided layout + nextlayer(): returns *next* layer in the current layout's direction parameter. + prevlayer(): returns *previous* layer in the current layout's direction parameter. + order(): compute *optimal* ordering of vertices within the layer. + """ + + __r = None + layout = None + upper = None + lower = None + __x = 1.0 + ccount = None + + def __eq__(self, other): + return super().__eq__(other) + + def __str__(self): + s = " 1: + self.__x = 1.0 / (len(self) - 1) + for i, v in enumerate(self): + assert layout.grx[v].rank == r + layout.grx[v].pos = i + layout.grx[v].bar = i * self.__x + if r > 0: + self.upper = layout.layers[r - 1] + if r < len(layout.layers) - 1: + self.lower = layout.layers[r + 1] + + def nextlayer(self): + return self.lower if self.layout.dirv == -1 else self.upper + + def prevlayer(self): + return self.lower if self.layout.dirv == +1 else self.upper + + def order(self): + sug = self.layout + sug._edge_inverter() + c = self._cc() + if c > 0: + for v in self: + sug.grx[v].bar = self._meanvalueattr(v) + # now resort layers l according to bar value: + self.sort(key=lambda x: sug.grx[x].bar) + # reduce & count crossings: + c = self._ordering_reduce_crossings() + # assign new position in layer l: + for i, v in enumerate(self): + sug.grx[v].pos = i + sug.grx[v].bar = i * self.__x + sug._edge_inverter() + self.ccount = c + return c + + def _meanvalueattr(self, v): + """ + find new position of vertex v according to adjacency in prevlayer. + position is given by the mean value of adjacent positions. + experiments show that meanvalue heuristic performs better than median. + """ + sug = self.layout + if not self.prevlayer(): + return sug.grx[v].bar + bars = [sug.grx[x].bar for x in self._neighbors(v)] + return sug.grx[v].bar if len(bars) == 0 else float(sum(bars)) / len(bars) + + def _medianindex(self, v): + """ + find new position of vertex v according to adjacency in layer l+dir. + position is given by the median value of adjacent positions. + median heuristic is proven to achieve at most 3 times the minimum + of crossings (while barycenter achieve in theory the order of |V|) + """ + assert self.prevlayer() != None + N = self._neighbors(v) + g = self.layout.grx + pos = [g[x].pos for x in N] + lp = len(pos) + if lp == 0: + return [] + pos.sort() + pos = pos[:: self.layout.dirh] + i, j = divmod(lp - 1, 2) + return [pos[i]] if j == 0 else [pos[i], pos[i + j]] + + def _neighbors(self, v): + """ + neighbors refer to upper/lower adjacent nodes. + Note that v.N() provides neighbors of v in the graph, while + this method provides the Vertex and DummyVertex adjacent to v in the + upper or lower layer (depending on layout.dirv state). + """ + assert self.layout.dag + dirv = self.layout.dirv + grxv = self.layout.grx[v] + try: # (cache) + return grxv.nvs[dirv] + except AttributeError: + grxv.nvs = {-1: v.N(-1), +1: v.N(+1)} + if grxv.dummy: + return grxv.nvs[dirv] + # v is real, v.N are graph neigbors but we need layers neighbors + for d in (-1, +1): + tr = grxv.rank + d + for i, x in enumerate(v.N(d)): + if self.layout.grx[x].rank == tr: + continue + e = v.e_with(x) + dum = self.layout.ctrls[e][tr] + grxv.nvs[d][i] = dum + return grxv.nvs[dirv] + + def _crossings(self): + """ + counts (inefficently but at least accurately) the number of + crossing edges between layer l and l+dirv. + P[i][j] counts the number of crossings from j-th edge of vertex i. + The total count of crossings is the sum of flattened P: + x = sum(sum(P,[])) + """ + g = self.layout.grx + P = [] + for v in self: + P.append([g[x].pos for x in self._neighbors(v)]) + for i, p in enumerate(P): + candidates = sum(P[i + 1 :], []) + for j, e in enumerate(p): + p[j] = len(filter((lambda nx: nx < e), candidates)) + del candidates + return P + + def _cc(self): + """ + implementation of the efficient bilayer cross counting by insert-sort + (see Barth & Mutzel paper "Simple and Efficient Bilayer Cross Counting") + """ + g = self.layout.grx + P = [] + for v in self: + P.extend(sorted([g[x].pos for x in self._neighbors(v)])) + # count inversions in P: + s = [] + count = 0 + for i, p in enumerate(P): + j = bisect(s, p) + if j < i: + count += i - j + s.insert(j, p) + return count + + def _ordering_reduce_crossings(self): + assert self.layout.dag + g = self.layout.grx + N = len(self) + X = 0 + for i, j in zip(range(N - 1), range(1, N)): + vi = self[i] + vj = self[j] + ni = [g[v].bar for v in self._neighbors(vi)] + Xij = Xji = 0 + for nj in [g[v].bar for v in self._neighbors(vj)]: + x = len([nx for nx in ni if nx > nj]) + Xij += x + Xji += len(ni) - x + if Xji < Xij: + self[i] = vj + self[j] = vi + X += Xji + else: + X += Xij + return X + + +# ------------------------------------------------------------------------------ + + +class SugiyamaLayout(object): + """ + The Sugiyama layout is the traditional "layered" graph layout called + *dot* in graphviz. This layout is quite efficient but heavily relies + on drawing heuristics. Adaptive drawing is limited to + extending the leaves only, but since the algorithm is quite fast + redrawing the entire graph (up to about a thousand nodes) gives + usually good results in less than a second. + + The Sugiyama Layout Class takes as input a core_graph object and implements + an efficient drawing algorithm based on nodes dimensions provided through + a user-defined *view* property in each vertex. + + Attributes: + dirvh (int): the current aligment state + for alignment policy: + dirvh=0 -> dirh=+1, dirv=-1: leftmost upper + dirvh=1 -> dirh=-1, dirv=-1: rightmost upper + dirvh=2 -> dirh=+1, dirv=+1: leftmost lower + dirvh=3 -> dirh=-1, dirv=+1: rightmost lower + order_inter (int): the default number of layer placement iterations + order_attr (str): set attribute name used for layer ordering + xspace (int): horizontal space between vertices in a layer + yspace (int): vertical space between layers + dw (int): default width of a vertex + dh (int): default height of a vertex + g (graph_core): the graph component reference + layers (list[Layer]): the list of layers + grx (dict): associate vertex (possibly dummy) with their sugiyama attributes + ctrls (dict): associate edge with all its vertices (including dummies) + dag (bool): the current acyclic state + initdone (bool): True if state is initialized (see init_all). + """ + + def __init__(self, g): + from .utils.geometry import median_wh + + # drawing parameters: + self.dirvh = 0 + self.order_iter = 8 + self.order_attr = "pos" + self.xspace = 20 + self.yspace = 20 + self.dw = 10 + self.dh = 10 + # For layered graphs, vertices and edges need to have some additional + # attributes that make sense only for this kind of layout: + # update graph struct: + self.g = g + self.layers = [] + self.grx = {} + self.ctrls = {} + self.dag = False + for v in self.g.V(): + assert hasattr(v, "view") + self.grx[v] = _sugiyama_vertex_attr() + self.dw, self.dh = median_wh([v.view for v in self.g.V()]) + self.initdone = False + + def init_all(self, roots=None, inverted_edges=None, optimize=False): + """initializes the layout algorithm by computing roots (unless provided), + inverted edges (unless provided), vertices ranks and creates all dummy + vertices and layers. + + Parameters: + roots (list[Vertex]): set *root* vertices (layer 0) + inverted_edges (list[Edge]): set edges to invert to have a DAG. + optimize (bool): optimize ranking if True (default False) + """ + if self.initdone: + return + # For layered sugiyama algorithm, the input graph must be acyclic, + # so we must provide a list of root nodes and a list of inverted edges. + if roots is None: + roots = [v for v in self.g.sV if len(v.e_in()) == 0] + if inverted_edges is None: + _ = self.g.get_scs_with_feedback(roots) + inverted_edges = [x for x in self.g.sE if x.feedback] + self.alt_e = inverted_edges + # assign rank to all vertices: + self.rank_all(roots, optimize) + # add dummy vertex/edge for 'long' edges: + for e in self.g.E(): + self.setdummies(e) + # precompute some layers values: + for l in self.layers: + l.setup(self) + self.initdone = True + + def draw(self, N=1.5): + """compute every node coordinates after converging to optimal ordering by N + rounds, and finally perform the edge routing. + """ + while N > 0.5: + for (l, mvmt) in self.ordering_step(): + pass + N = N - 1 + if N > 0: + for (l, mvmt) in self.ordering_step(oneway=True): + pass + self.setxy() + self.draw_edges() + + def _edge_inverter(self): + for e in self.alt_e: + x, y = e.v + e.v = (y, x) + self.dag = not self.dag + if self.dag: + for e in self.g.degenerated_edges: + e.detach() + self.g.sE.remove(e) + else: + for e in self.g.degenerated_edges: + self.g.add_edge(e) + + @property + def dirvh(self): + return self.__dirvh + + @property + def dirv(self): + return self.__dirv + + @property + def dirh(self): + return self.__dirh + + @dirvh.setter + def dirvh(self, dirvh): + assert dirvh in range(4) + self.__dirvh = dirvh + self.__dirh, self.__dirv = {0: (1, -1), + 1: (-1, -1), + 2: (1, 1), + 3: (-1, 1)}[dirvh] + + @dirv.setter + def dirv(self, dirv): + assert dirv in (-1, +1) + dirvh = (dirv + 1) + (1 - self.__dirh) // 2 + self.dirvh = dirvh + + @dirh.setter + def dirh(self, dirh): + assert dirh in (-1, +1) + dirvh = (self.__dirv + 1) + (1 - dirh) // 2 + self.dirvh = dirvh + + def rank_all(self, roots, optimize=False): + """Computes rank of all vertices. + add provided roots to rank 0 vertices, + otherwise update ranking from provided roots. + The initial rank is based on precedence relationships, + optimal ranking may be derived from network flow (simplex). + """ + self._edge_inverter() + r = [x for x in self.g.sV if (len(x.e_in()) == 0 and x not in roots)] + self._rank_init(roots + r) + if optimize: + self._rank_optimize() + self._edge_inverter() + + def _rank_init(self, unranked): + """Computes rank of provided unranked list of vertices and all + their children. A vertex will be asign a rank when all its + inward edges have been *scanned*. When a vertex is asigned + a rank, its outward edges are marked *scanned*. + """ + assert self.dag + scan = {} + # set rank of unranked based on its in-edges vertices ranks: + while len(unranked) > 0: + l = [] + for v in unranked: + self.setrank(v) + # mark out-edges has scan-able: + for e in v.e_out(): + scan[e] = True + # check if out-vertices are rank-able: + for x in v.N(+1): + if not (False in [scan.get(e, False) for e in x.e_in()]): + if x not in l: + l.append(x) + unranked = l + + def _rank_optimize(self): + """optimize ranking by pushing long edges toward lower layers as much as possible. + see other interersting network flow solver to minimize total edge length + (http://jgaa.info/accepted/2005/EiglspergerSiebenhallerKaufmann2005.9.3.pdf) + """ + assert self.dag + for l in reversed(self.layers): + for v in l: + gv = self.grx[v] + for x in v.N(-1): + if all((self.grx[y].rank >= gv.rank for y in x.N(+1))): + gx = self.grx[x] + self.layers[gx.rank].remove(x) + gx.rank = gv.rank - 1 + self.layers[gv.rank - 1].append(x) + + def setrank(self, v): + """set rank value for vertex v and add it to the corresponding layer. + The Layer is created if it is the first vertex with this rank. + """ + assert self.dag + r = max([self.grx[x].rank for x in v.N(-1)] + [-1]) + 1 + self.grx[v].rank = r + # add it to its layer: + try: + self.layers[r].append(v) + except IndexError: + assert r == len(self.layers) + self.layers.append(Layer([v])) + + def dummyctrl(self, r, ctrl): + """creates a DummyVertex at rank r inserted in the ctrl dict + of the associated edge and layer. + + Arguments: + r (int): rank value + ctrl (dict): the edge's control vertices + + Returns: + DummyVertex : the created DummyVertex. + """ + dv = DummyVertex(r) + dv.view.w, dv.view.h = self.dw, self.dh + self.grx[dv] = dv + dv.ctrl = ctrl + ctrl[r] = dv + self.layers[r].append(dv) + return dv + + def setdummies(self, e): + """creates and defines all needed dummy vertices for edge e. + """ + v0, v1 = e.v + r0, r1 = self.grx[v0].rank, self.grx[v1].rank + if r0 > r1: + assert e in self.alt_e + v0, v1 = v1, v0 + r0, r1 = r1, r0 + if (r1 - r0) > 1: + # "dummy vertices" are stored in the edge ctrl dict, + # keyed by their rank in layers. + ctrl = self.ctrls[e] = {} + ctrl[r0] = v0 + ctrl[r1] = v1 + for r in range(r0 + 1, r1): + self.dummyctrl(r, ctrl) + + def draw_step(self): + """iterator that computes all vertices coordinates and edge routing after + just one step (one layer after the other from top to bottom to top). + Purely inefficient ! Use it only for "animation" or debugging purpose. + """ + ostep = self.ordering_step() + for s in ostep: + self.setxy() + self.draw_edges() + yield s + + def ordering_step(self, oneway=False): + """iterator that computes all vertices ordering in their layers + (one layer after the other from top to bottom, to top again unless + oneway is True). + """ + self.dirv = -1 + crossings = 0 + for l in self.layers: + mvmt = l.order() + crossings += mvmt + yield (l, mvmt) + if oneway or (crossings == 0): + return + self.dirv = +1 + while l: + mvmt = l.order() + yield (l, mvmt) + l = l.nextlayer() + + def setxy(self): + """computes all vertex coordinates (x,y) using + an algorithm by Brandes & Kopf. + """ + self._edge_inverter() + self._detect_alignment_conflicts() + inf = float("infinity") + # initialize vertex coordinates attributes: + for l in self.layers: + for v in l: + self.grx[v].root = v + self.grx[v].align = v + self.grx[v].sink = v + self.grx[v].shift = inf + self.grx[v].X = None + self.grx[v].x = [0.0] * 4 + curvh = self.dirvh # save current dirvh value + for dirvh in range(4): + self.dirvh = dirvh + self._coord_vertical_alignment() + self._coord_horizontal_compact() + self.dirvh = curvh # restore it + # vertical coordinate assigment of all nodes: + Y = 0 + for l in self.layers: + dY = max([v.view.h / 2.0 for v in l]) + for v in l: + vx = sorted(self.grx[v].x) + # mean of the 2 medians out of the 4 x-coord computed above: + avgm = (vx[1] + vx[2]) / 2.0 + # final xy-coordinates : + v.view.xy = (avgm, Y + dY) + + # SORT by computed X to ensure correct order for overlap detection + l.sort(key=lambda v: v.view.xy[0]) + + # POST-PROCESSING: Fix any overlaps caused by BK variable-width averaging + for i in range(1, len(l)): + v_prev = l[i-1] + v_curr = l[i] + min_dist = self.xspace + (v_prev.view.w + v_curr.view.w) / 2.0 + if v_curr.view.xy[0] < v_prev.view.xy[0] + min_dist: + v_curr.view.xy = (v_prev.view.xy[0] + min_dist, v_curr.view.xy[1]) + + Y += 2 * dY + self.yspace + self._edge_inverter() + + def _detect_alignment_conflicts(self): + """mark conflicts between edges: + inner edges are edges between dummy nodes + type 0 is regular crossing regular (or sharing vertex) + type 1 is inner crossing regular (targeted crossings) + type 2 is inner crossing inner (avoided by reduce_crossings phase) + """ + curvh = self.dirvh # save current dirvh value + self.dirvh = 0 + self.conflicts = [] + for L in self.layers: + last = len(L) - 1 + prev = L.prevlayer() + if not prev: + continue + k0 = 0 + k1_init = len(prev) - 1 + l = 0 + for l1, v in enumerate(L): + if not self.grx[v].dummy: + continue + if l1 == last or v.inner(-1): + k1 = k1_init + if v.inner(-1): + k1 = self.grx[v.N(-1)[-1]].pos + for vl in L[l : l1 + 1]: + for vk in L._neighbors(vl): + k = self.grx[vk].pos + if k < k0 or k > k1: + self.conflicts.append((vk, vl)) + l = l1 + 1 + k0 = k1 + self.dirvh = curvh # restore it + + def _coord_vertical_alignment(self): + """performs vertical alignment according to current dirvh internal state. + """ + dirh, dirv = self.dirh, self.dirv + g = self.grx + for l in self.layers[::-dirv]: + if not l.prevlayer(): + continue + r = None + for vk in l[::dirh]: + for m in l._medianindex(vk): + # take the median node in dirv layer: + um = l.prevlayer()[m] + # if vk is "free" align it with um's root + if g[vk].align is vk: + if dirv == 1: + vpair = (vk, um) + else: + vpair = (um, vk) + # if vk<->um link is used for alignment + if (vpair not in self.conflicts) and ( + (r is None) or (dirh * r < dirh * m) + ): + g[um].align = vk + g[vk].root = g[um].root + g[vk].align = g[vk].root + r = m + + def _coord_horizontal_compact(self): + limit = getrecursionlimit() + N = len(self.layers) + 10 + if N > limit: + setrecursionlimit(N) + dirh, dirv = self.dirh, self.dirv + g = self.grx + L = self.layers[::-dirv] + # recursive placement of blocks: + for l in L: + for v in l[::dirh]: + if g[v].root is v: + self.__place_block(v) + setrecursionlimit(limit) + # mirror all nodes if right-aligned: + if dirh == -1: + for l in L: + for v in l: + x = g[v].X + if x: + g[v].X = -x + # then assign x-coord of its root: + inf = float("infinity") + rb = inf + for l in L: + for v in l[::dirh]: + g[v].x[self.dirvh] = g[g[v].root].X + rs = g[g[v].root].sink + s = g[rs].shift + if s < inf: + g[v].x[self.dirvh] += dirh * s + rb = min(rb, g[v].x[self.dirvh]) + # normalize to 0, and reinit root/align/sink/shift/X + for l in self.layers: + for v in l: + # g[v].x[dirvh] -= rb + g[v].root = g[v].align = g[v].sink = v + g[v].shift = inf + g[v].X = None + + # TODO: rewrite in iterative form to avoid recursion limit... + def __place_block(self, v): + g = self.grx + if g[v].X is None: + # every block is initially placed at x=0 + g[v].X = 0.0 + # place block in which v belongs: + w = v + while 1: + j = g[w].pos - self.dirh # predecessor in rank must be placed + r = g[w].rank + if 0 <= j < len(self.layers[r]): + wprec = self.layers[r][j] + delta = ( + self.xspace + (wprec.view.w + w.view.w) / 2.0 + ) # abs positive minimum displ. + # take root and place block: + u = g[wprec].root + self.__place_block(u) + # set sink as sink of prec-block root + if g[v].sink is v: + g[v].sink = g[u].sink + if g[v].sink != g[u].sink: + s = g[u].sink + newshift = g[v].X - (g[u].X + delta) + g[s].shift = min(g[s].shift, newshift) + else: + g[v].X = max(g[v].X, (g[u].X + delta)) + # take next node to align in block: + w = g[w].align + # quit if self aligned + if w is v: + break + + def draw_edges(self): + """Basic edge routing applied only for edges with dummy points. + Enhanced edge routing can be performed by using the apropriate + *route_with_xxx* functions from :ref:routing_ in the edges' view. + """ + for e in self.g.E(): + if hasattr(e, "view"): + l = [] + if e in self.ctrls: + D = self.ctrls[e] + r0, r1 = self.grx[e.v[0]].rank, self.grx[e.v[1]].rank + if r0 < r1: + ranks = range(r0 + 1, r1) + else: + ranks = range(r0 - 1, r1, -1) + l = [D[r].view.xy for r in ranks] + l.insert(0, e.v[0].view.xy) + l.append(e.v[1].view.xy) + try: + self.route_edge(e, l) + except AttributeError: + pass + e.view.setpath(l) + + +# DIRECTED GRAPH WITH CONSTRAINTS LAYOUT +# ------------------------------------------------------------------------------ + + +class DigcoLayout(object): + linalg = importlib.import_module(__package__ + ".utils.geometry") + + def __init__(self, g): + # drawing parameters: + self.xspace = 10 + self.yspace = 10 + self.dr = 10 + self.debug = False + + self.g = g + self.levels = [] + for i, v in enumerate(self.g.V()): + assert hasattr(v, "view") + v.i = i + self.dr = max((self.dr, v.view.w, v.view.h)) + # solver parameters: + self._cg_max_iter = g.order() + self._cg_tolerance = 1.0e-6 + self._eps = 1.0e-5 + self._cv_max_iter = self._cg_max_iter + + def init_all(self, alpha=0.1, beta=0.01): + y = None + if self.g.directed: + # partition g in hierarchical levels: + y = self.part_to_levels(alpha, beta) + # initiate positions (y and random in x): + self.Z = self._xyinit(y) + + def draw(self, N=None): + if N is None: + N = self._cv_max_iter + self.Z = self._optimize(self.Z, limit=N) + # set view xy from near-optimal coords matrix: + for v in self.g.V(): + v.view.xy = (self.Z[v.i][0, 0] * self.dr, self.Z[v.i][0, 1] * self.dr) + self.draw_edges() + + def draw_step(self): + for x in range(self._cv_max_iter): + self.draw(N=1) + self.draw_edges() + yield + + # Basic edge routing with segments + def draw_edges(self): + for e in self.g.E(): + if hasattr(e, "view"): + l = [e.v[0].view.xy, e.v[1].view.xy] + try: + self.route_edge(e, l) + except AttributeError: + pass + e.view.setpath(l) + + # partition the nodes into levels: + def part_to_levels(self, alpha, beta): + opty, err = self.optimal_arrangement() + ordering = list(zip(opty, self.g.sV)) + eps = alpha * (opty.max() - opty.min()) / (len(opty) - 1) + eps = max(beta, eps) + sorted(ordering, reverse=True) + l = [] + self.levels.append(l) + for i in range(len(list(ordering)) - 1): + y, v = ordering[i] + l.append(v) + v.level = self.levels.index(l) + if (y - ordering[i + 1][0]) > eps: + l = [] + self.levels.append(l) + y, v = ordering[-1] + l.append(v) + v.level = self.levels.index(l) + return opty + + def optimal_arrangement(self): + b = self.balance() + y = DigcoLayout.linalg.rand_ortho1(self.g.order()) + return self._conjugate_gradient_L(y, b) + + # balance vector is assembled in finite-element way... + # this is faster than computing b[i] for each i. + def balance(self): + b = DigcoLayout.linalg.array([0.0] * self.g.order(), dtype=float) + for e in self.g.E(): + s = e.v[0] + d = e.v[1] + q = e.w * (self.yspace + (s.view.h + d.view.h) / 2.0) + b[s.i] += q + b[d.i] -= q + return b + + # We compute the solution Y of L.Y = b by conjugate gradient method + # (L is semi-definite positive so Y is unique and convergence is O(n)) + # note that only arrays are involved here... + def _conjugate_gradient_L(self, y, b): + Lii = self.__Lii_() + r = b - self.__L_pk(Lii, y) + p = DigcoLayout.linalg.array(r, copy=True) + rr = sum(r * r) + for k in range(self._cg_max_iter): + try: + Lp = self.__L_pk(Lii, p) + alpha = rr / sum(p * Lp) + y += alpha / p + r -= alpha * Lp + newrr = sum(r * r) + beta = newrr / rr + rr = newrr + if rr < self._cg_tolerance: + break + p = r + beta * p + except ZeroDivisionError: + return (None, rr) + return (y, rr) + + # _xyinit can use diagonally scaled initial vertices positioning to provide + # better convergence in constrained stress majorization + def _xyinit(self, y=None): + if y is None: + y = DigcoLayout.linalg.rand_ortho1(self.g.order()) + x = DigcoLayout.linalg.rand_ortho1(self.g.order()) + # translate and normalize: + x = x - x[0] + y = y - y[0] + sfactor = 1.0 / max(list(map(abs, y)) + list(map(abs, x))) + return DigcoLayout.linalg.matrix(list(zip(x * sfactor, y * sfactor))) + + # provide the diagonal of the Laplacian matrix of g + # the rest of L (sparse!) is already stored in every edges. + def __Lii_(self): + Lii = [] + for v in self.g.V(): + Lii.append(sum([e.w for e in v.e])) + return DigcoLayout.linalg.array(Lii, dtype=float) + + # we don't compute the L.Pk matrix/vector product here since + # L is sparse (order of |E| not |V|^2 !) so we let each edge + # contribute to the resulting L.Pk vector in a FE assembly way... + def __L_pk(self, Lii, pk): + y = Lii * pk + for e in self.g.sE: + i1 = e.v[0].i + i2 = e.v[1].i + y[i1] -= e.w * pk[i2] + y[i2] -= e.w * pk[i1] + return y + + # conjugate_gradient with given matrix Lw: + # it is assumed that b is not a multivector, + # so _cg_Lw should be called in all directions separately. + # note that everything is a matrix here, (arrays are row vectors only) + def _cg_Lw(self, Lw, z, b): + scal = lambda U, V: float(U.transpose() * V) + r = b - Lw * z + p = r.copy() + rr = scal(r, r) + for k in range(self._cg_max_iter): + if rr < self._cg_tolerance: + break + Lp = Lw * p + alpha = rr / scal(p, Lp) + z = z + alpha * p + r = r - alpha * Lp + newrr = scal(r, r) + beta = newrr / rr + rr = newrr + p = r + beta * p + return (z, rr) + + def __Dij_(self): + Dji = [] + for v in self.g.V(): + wd = self.g.dijkstra(v) + Di = [wd[w] for w in self.g.V()] + Dji.append(Di) + # at this point D is stored by rows, + # but anymway it's a symmetric matrix + return DigcoLayout.linalg.matrix(Dji, dtype=float) + + # returns matrix -L^w + def __Lij_w_(self): + self.Dij = self.__Dij_() # we keep D also for L^Z computations + Lij = self.Dij.copy() + n = self.g.order() + for i in range(n): + d = 0 + for j in range(n): + if j == i: + continue + Lij[i, j] = 1.0 / self.Dij[i, j] ** 2 + d += Lij[i, j] + Lij[i, i] = -d + return Lij + + # returns vector -L^Z.Z: + def __Lij_Z_Z(self, Z): + n = self.g.order() + # init: + lzz = Z.copy() * 0.0 # lzz has dim Z (n x 2) + liz = DigcoLayout.linalg.matrix([0.0] * n) # liz is a row of L^Z (size n) + # compute lzz = L^Z.Z while assembling L^Z by row (liz): + for i in range(n): + iterk_except_i = (k for k in range(n) if k != i) + for k in iterk_except_i: + v = Z[i] - Z[k] + liz[0, k] = 1.0 / ( + self.Dij[i, k] * DigcoLayout.linalg.sqrt(v * v.transpose()) + ) + liz[0, i] = 0.0 # forced, otherwise next liz.sum() is wrong ! + liz[0, i] = -liz.sum() + # now that we have the i-th row of L^Z, just dotprod with Z: + lzz[i] = liz * Z + return lzz + + def _optimize(self, Z, limit=100): + Lw = self.__Lij_w_() + K = self.g.order() * (self.g.order() - 1.0) / 2.0 + stress = float("inf") + count = 0 + deep = 0 + b = self.__Lij_Z_Z(Z) + while count < limit: + if self.debug: + print("count %d" % count) + print("Z = ", Z) + print("b = ", b) + # find next Z by solving Lw.Z = b in every direction: + x, xerr = self._cg_Lw(Lw[1:, 1:], Z[1:, 0], b[1:, 0]) + y, yerr = self._cg_Lw(Lw[1:, 1:], Z[1:, 1], b[1:, 1]) + Z[1:, 0] = x + Z[1:, 1] = y + if self.debug: + print(" cg -> ") + print(Z, xerr, yerr) + # compute new stress: + FZ = K - float(x.transpose() * b[1:, 0] + y.transpose() * b[1:, 1]) + # precompute new b: + b = self.__Lij_Z_Z(Z) + # update new stress: + FZ += 2 * float(x.transpose() * b[1:, 0] + y.transpose() * b[1:, 1]) + # test convergence: + print("stress=%.10f" % FZ) + if stress == 0.0: + break + elif abs((stress - FZ) / stress) < self._eps: + if deep == 2: + break + else: + deep += 1 + stress = FZ + count += 1 + return Z + + +# ------------------------------------------------------------------------------ +class DwyerLayout(DigcoLayout): + pass diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/routing.py b/backends/qualcomm/utils/fx_viewer/grandalf/routing.py new file mode 100644 index 00000000000..dd4a32192b6 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/routing.py @@ -0,0 +1,132 @@ +# This code is part of Grandalf +# Copyright (C) 2011 Axel Tillequin (bdcht3@gmail.com) and others +# published under GPLv2 license or EPLv1 license +# Contributor(s): Axel Tillequin, Fabio Zadrozny + +# Edge routing algorithms. +# These are mosty helpers for routing an edge 'e' through +# points pts with various tweaks like moving the starting point +# to the intersection with the bounding box and taking some constraints +# into account, and/or moving the head also to its prefered position. +# Of course, since gandalf only works with bounding boxes, the exact +# shape of the nodes are not known and the edge drawing inside the bb +# shall be performed by the drawing engine associated with 'views'. +# (e.g. look at intersectC when the node shape is a circle) + +from .utils.geometry import intersectR, getangle, sqrt + +# ------------------------------------------------------------------------------ +class EdgeViewer(object): + def setpath(self, pts): + self._pts = pts + + +# ------------------------------------------------------------------------------ +# basic edge routing with lines : nothing to do for routing +# since the layout engine has already provided to list of points through which +# the edge shall be drawn. We just compute the position where to adjust the +# tail and head. +def route_with_lines(e, pts): + assert hasattr(e, "view") + tail_pos = intersectR(e.v[0].view, topt=pts[1]) + head_pos = intersectR(e.v[1].view, topt=pts[-2]) + pts[0] = tail_pos + pts[-1] = head_pos + e.view.head_angle = getangle(pts[-2], pts[-1]) + + +# ------------------------------------------------------------------------------ +# enhanced edge routing where 'corners' of the above polyline route are +# rounded with a bezier curve. +def route_with_splines(e, pts): + from .utils.geometry import setroundcorner + + route_with_lines(e, pts) + splines = setroundcorner(e, pts) + e.view.splines = splines + + +def _gen_point(p1, p2, new_distance): + from .utils.geometry import new_point_at_distance + + initial_distance = distance = sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2) + if initial_distance < 1e-10: + return None + if distance > new_distance: + distance = distance - new_distance + else: + return None + angle = getangle(p1, p2) + new = new_point_at_distance(p1, distance, angle) + return new + + +def _gen_smoother_middle_points_from_3_points(pts, initial): + p1 = pts[0] + p2 = pts[1] + p3 = pts[2] + distance1 = sqrt((p2[0] - p1[0]) ** 2 + (p2[1] - p1[1]) ** 2) + distance2 = sqrt((p3[0] - p1[0]) ** 2 + (p3[1] - p1[1]) ** 2) + if distance1 < 1e-10 or distance2 < 1e-10: + yield p2 + else: + if distance1 < initial or distance2 < initial: + yield p2 + else: + p2a = _gen_point(p1, p2, initial) + p2b = _gen_point(p3, p2, initial) + if p2a is None or p2b is None: + yield p2 + else: + yield p2a + yield p2b + + +# Future work: possibly work better when we already have 4 points? +# maybe: http://stackoverflow.com/questions/1251438/catmull-rom-splines-in-python +def _round_corners(pts, round_at_distance): + if len(pts) > 2: + calc_with_distance = round_at_distance + while calc_with_distance > 0.5: + new_lst = [pts[0]] + for i, curr in enumerate(pts[1:-1]): + i += 1 + p1 = pts[i - 1] + p2 = curr + p3 = pts[i + 1] + if len(pts) > 3: + # i.e.: at least 4 points + if sqrt((p3[0] - p2[0]) ** 2 + (p3[1] - p2[1]) ** 2) < ( + 2 * calc_with_distance + ): + # prevent from crossing over. + new_lst.append(p2) + continue + generated = _gen_smoother_middle_points_from_3_points( + [p1, p2, p3], calc_with_distance + ) + for j in generated: + new_lst.append(j) + new_lst.append(pts[-1]) + pts = new_lst + calc_with_distance /= 2.0 + return pts + + +# ------------------------------------------------------------------------------ +# Routing with a custom algorithm to round corners +# It works by generating new points up to a distance from where an edge is +# found (and then iteratively refining based on that). +# This is a custom implementation as this interpolation method worked +# well for me where others weren't so great. + +# This is the point where it'll start rounding from an edge. +# (can be changed to decide up to which distance it starts +# rounding from an edge). +ROUND_AT_DISTANCE = 40 + + +def route_with_rounded_corners(e, pts): + route_with_lines(e, pts) + new_pts = _round_corners(pts, round_at_distance=ROUND_AT_DISTANCE) + pts[:] = new_pts[:] diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/__init__.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/__init__.py new file mode 100644 index 00000000000..581782d01bf --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/__init__.py @@ -0,0 +1,3 @@ +from .poset import * +from .dot import * +from .nx import * diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/dot.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/dot.py new file mode 100644 index 00000000000..ca89132ea17 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/dot.py @@ -0,0 +1,401 @@ +# This code is part of Grandalf +# Copyright (C) 2008 Axel Tillequin (bdcht3@gmail.com) and others +# published under GPLv2 license or EPLv1 license +# Contributor(s): Axel Tillequin + +try: + import ply.lex as lex + import ply.yacc as yacc + + _has_ply = True +except ImportError: + _has_ply = False + +__all__ = ["_has_ply", "Dot"] + +# ------------------------------------------------------------------------------ +# LALR(1) parser for Graphviz dot file format. +class Dot: + + _reserved = ( + "strict", + "graph", + "digraph", + "subgraph", + "node", + "edge", + ) + _tokens = ("regulars", "string", "html", "comment",) + _reserved + + _literals = [",", ";", "-", ">", "=", ":", "[", "]", "{", "}"] + + class Lexer(object): + def __init__(self): + self.whitespace = "\0\t\n\f\r " + self.reserved = Dot._reserved + self.tokens = Dot._tokens + self.literals = Dot._literals + self.t_ignore = self.whitespace + + def t_regulars(self, t): + r"[-]?[\w.]+" + v = t.value.lower() + if v in self.reserved: + t.type = v + return t + # check numeric string + if v[0].isdigit() or v[0] in ["-", "."]: + try: + float(v) + except ValueError: + print("invalid numeral token: %s" % v) + raise SyntaxError + elif "." in v: # forbidden in non-numeric + raise SyntaxError + return t + + def t_comment_online(self, t): + r"(//(.*)\n)|\\\n" + pass + + def t_comment_macro(self, t): + r"(\#(.*)\n)" + pass + + def t_comment_multline(self, t): + r"(/\*)" + start = t.lexer.lexpos + t.lexer.lexpos = t.lexer.lexdata.index("*/", start) + 2 + + def t_string(self, t): + r'"' + start = t.lexer.lexpos - 1 + i = t.lexer.lexdata.index('"', start + 1) + while t.lexer.lexdata[i - 1] == "\\": + i = t.lexer.lexdata.index('"', i + 1) + t.value = t.lexer.lexdata[start : i + 1] + t.lexer.lexpos = i + 1 + return t + + def t_html(self, t): + r"<" + start = t.lexer.lexpos - 1 + level = 1 + i = start + 1 + while level > 0: + c = t.lexer.lexdata[i] + if c == "<": + level += 1 + if c == ">": + level -= 1 + i += 1 + t.value = t.lexer.lexdata[start:i] + t.lexer.lexpos = i + return t + + def t_ANY_error(self, t): + print("Illegal character '%s'" % t.value[0]) + t.lexer.skip(1) + + def build(self, **kargs): + if _has_ply: + self._lexer = lex.lex(module=self, **kargs) + + def test(self, data): + self._lexer.input(data) + while 1: + tok = self._lexer.token() + if not tok: + break + print(tok) + + # Classes for the AST returned by Parser: + class graph(object): + def __init__(self, name, data, strict=None, direct=None): + self.name = name + self.strict = strict + self.direct = direct + self.nodes = {} + self.edges = [] + self.subgraphs = [] + self.attr = {} + eattr = {} + nattr = {} + for x in data: # data is a statements (list of stmt) + # x is a stmt, ie one of: + # a graph object (subgraph) + # a attr object (graph/node/edge attributes) + # a dict object (ID=ID) + # a node object + # a list of edges + if isinstance(x, Dot.graph): + self.subgraphs.append(x) + elif isinstance(x, Dot.attr): + if x.type == "graph": + self.attr.update(x.D) + elif x.type == "node": + nattr.update(x.D) + elif x.type == "edge": + eattr.update(x.D) + else: + raise TypeError("invalid attribute type") + elif isinstance(x, dict): + self.attr.update(x) + elif isinstance(x, Dot.node): + x.attr.update(nattr) + self.nodes[x.name] = x + else: + for e in x: + e.attr.update(eattr) + self.edges.append(e) + for n in [e.n1, e.n2]: + if isinstance(n, Dot.graph): + continue + if n.name not in self.nodes: + n.attr.update(nattr) + self.nodes[n.name] = n + + def __repr__(self): + u = "<%s instance at %x, name: %s, %d nodes>" % ( + self.__class__, + id(self), + self.name, + len(self.nodes), + ) + return u + + class attr(object): + def __init__(self, type, D): + self.type = type + self.D = D + + class edge(object): + def __init__(self, n1, n2): + self.n1 = n1 + self.n2 = n2 + self.attr = {} + + class node(object): + def __init__(self, name, port=None): + self.name = name + self.port = port + self.attr = {} + + class Parser(object): + def __init__(self): + self.tokens = Dot._tokens + + def __makelist(self, p): + N = len(p) + if N > 2: + L = p[1] + L.append(p[N - 1]) + else: + L = [] + if N > 1: + L.append(p[N - 1]) + p[0] = L + + def p_Data(self, p): + """Data : Data Graph + | Graph""" + self.__makelist(p) + + def p_Graph_strict(self, p): + """Graph : strict graph name Block""" + p[0] = Dot.graph(name=p[3], data=p[4], strict=1, direct=0) + # print 'Dot.Parser: graph object %s created'%p[0].name + + def p_Graph_graph(self, p): + """Graph : graph name Block""" + p[0] = Dot.graph(name=p[2], data=p[3], strict=0, direct=0) + + def p_Graph_strict_digraph(self, p): + """Graph : strict digraph name Block""" + p[0] = Dot.graph(name=p[3], data=p[4], strict=1, direct=1) + + def p_Graph_digraph(self, p): + """Graph : digraph name Block""" + p[0] = Dot.graph(name=p[2], data=p[3], strict=0, direct=1) + + def p_ID(self, p): + """ID : regulars + | string + | html """ + p[0] = p[1] + + def p_name(self, p): + """name : ID + | """ + if len(p) == 1: + p[0] = "" + else: + p[0] = p[1] + + def p_Block(self, p): + """Block : '{' statements '}' """ + p[0] = p[2] + + def p_statements(self, p): + """statements : statements stmt + | stmt + | """ + self.__makelist(p) + + def p_stmt(self, p): + """stmt : stmt ';' """ + p[0] = p[1] + + def p_comment(self, p): + """stmt : comment""" + pass # comment tokens are not outputed by lexer anyway + + def p_stmt_sub(self, p): + """stmt : sub""" + p[0] = p[1] + + def p_subgraph(self, p): + """sub : subgraph name Block + | Block """ + N = len(p) + if N > 2: + ID = p[2] + else: + ID = "" + p[0] = Dot.graph(name=ID, data=p[N - 1], strict=0, direct=0) + + def p_stmt_assign(self, p): + """stmt : affect """ + p[0] = p[1] + + def p_affect(self, p): + """affect : ID '=' ID """ + p[0] = dict([(p[1], p[3])]) + + def p_stmt_lists(self, p): + """stmt : graph attrs + | node attrs + | edge attrs """ + p[0] = Dot.attr(p[1], p[2]) + + def p_attrs(self, p): + """attrs : attrs attrl + | attrl """ + if len(p) == 3: + p[1].update(p[2]) + p[0] = p[1] + + def p_attrl(self, p): + """attrl : '[' alist ']' """ + L = {} + for a in p[2]: + if isinstance(a, dict): + L.update(a) + else: + L[a] = "true" + p[0] = L + + def p_alist_comma(self, p): + """alist : alist ',' alist """ + p[1].extend(p[3]) + p[0] = p[1] + + def p_alist_affect(self, p): + """alist : alist affect + | alist ID + | affect + | ID + | """ + self.__makelist(p) + + def p_stmt_E_attrs(self, p): + """stmt : E attrs """ + for e in p[1]: + e.attr = p[2] + p[0] = p[1] + + def p_stmt_N_attrs(self, p): + """stmt : N attrs """ + p[1].attr = p[2] + p[0] = p[1] + + def p_stmt_EN(self, p): + """stmt : E + | N """ + p[0] = p[1] + + def p_E(self, p): + """E : E link + | elt link """ + try: + L = p[1] + L.append(Dot.edge(L[-1].n2, p[2])) + except Exception: + L = [] + L.append(Dot.edge(p[1], p[2])) + p[0] = L + + def p_elt(self, p): + """elt : N + | sub """ + p[0] = p[1] + + def p_link(self, p): + """link : '-' '>' elt + | '-' '-' elt """ + p[0] = p[3] + + def p_N_port(self, p): + """N : ID port """ + p[0] = Dot.node(p[1], port=p[2]) + + def p_N(self, p): + """N : ID """ + p[0] = Dot.node(p[1]) + + def p_port(self, p): + """port : ':' ID """ + p[0] = p[2] + + def p_port2(self, p): + """port : port port""" + assert p[2] in ["n", "ne", "e", "se", "s", "sw", "w", "nw", "c", "_"] + p[0] = "%s:%s" % (p[1], p[2]) + + def p_error(self, p): + print("Syntax Error: %s" % (p,)) + self._parser.restart() + + def build(self, **kargs): + opt = dict(debug=0, write_tables=0) + opt.update(**kargs) + if _has_ply: + self._parser = yacc.yacc(module=self, **opt) + + def __init__(self, **kargs): + self.lexer = Dot.Lexer() + self.parser = Dot.Parser() + if not _has_ply: + print("warning: Dot parser not supported (install python-ply)") + + def parse(self, data): + try: + self.parser._parser.restart() + except AttributeError: + self.lexer.build(reflags=lex.re.UNICODE) + self.parser.build() + except Exception: + print("unexpected error") + return None + try: + s = data.decode("utf-8") + except UnicodeDecodeError: + s = data + L = self.parser._parser.parse(s, lexer=self.lexer._lexer) + return L + + def read(self, filename): + f = open( + filename, "rb" + ) # As it'll try to decode later on with utf-8, read it binary at this point. + return self.parse(f.read()) diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/geometry.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/geometry.py new file mode 100644 index 00000000000..a9b39ce3fac --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/geometry.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python +# +# This code is part of Grandalf +# Copyright (C) 2008 Axel Tillequin (bdcht3@gmail.com) and others +# published under GPLv2 license or EPLv1 license +# Contributor(s): Axel Tillequin, Fabio Zadrozny +from .poset import * +from .dot import * + +from math import atan2, sqrt +from random import SystemRandom + +# ------------------------------------------------------------------------------ +def intersect2lines(xy1, xy2, xy3, xy4): + (x1, y1) = xy1 + (x2, y2) = xy2 + (x3, y3) = xy3 + (x4, y4) = xy4 + b = (x2 - x1, y2 - y1) + d = (x4 - x3, y4 - y3) + det = b[0] * d[1] - b[1] * d[0] + if det == 0: + return None + c = (x3 - x1, y3 - y1) + t = float(c[0] * b[1] - c[1] * b[0]) / (det * 1.0) + if t < 0.0 or t > 1.0: + return None + t = float(c[0] * d[1] - c[1] * d[0]) / (det * 1.0) + if t < 0.0 or t > 1.0: + return None + x = x1 + t * b[0] + y = y1 + t * b[1] + return (x, y) + + +# ------------------------------------------------------------------------------ +# intersectR returns the intersection point between the Rectangle +# (w,h) that characterize the view object and the line that goes +# from the views' object center to the 'topt' point. +def intersectR(view, topt): + # we compute intersection in local views' coord: + # center of view is obviously : + x1, y1 = 0, 0 + # endpoint in view's coord: + x2, y2 = topt[0] - view.xy[0], topt[1] - view.xy[1] + # bounding box: + bbx2 = view.w // 2 + bbx1 = -bbx2 + bby2 = view.h // 2 + bby1 = -bby2 + # all 4 segments of the bb: + S = [ + ((x1, y1), (x2, y2), (bbx1, bby1), (bbx2, bby1)), + ((x1, y1), (x2, y2), (bbx2, bby1), (bbx2, bby2)), + ((x1, y1), (x2, y2), (bbx1, bby2), (bbx2, bby2)), + ((x1, y1), (x2, y2), (bbx1, bby2), (bbx1, bby1)), + ] + # check intersection with each seg: + for segs in S: + xy = intersect2lines(*segs) + if xy != None: + x, y = xy + # return global coord: + x += view.xy[0] + y += view.xy[1] + return (x, y) + # there can't be no intersection unless the endpoint was + # inside the bb ! + raise ValueError( + "no intersection found (point inside ?!). view: %s topt: %s" % (view, topt) + ) + + +# ------------------------------------------------------------------------------ +def getangle(p1, p2): + x1, y1 = p1 + x2, y2 = p2 + theta = atan2(y2 - y1, x2 - x1) + return theta + + +# ------------------------------------------------------------------------------ +def median_wh(views): + mw = [v.w for v in views] + mh = [v.h for v in views] + mw.sort() + mh.sort() + return (mw[len(mw) // 2], mh[len(mh) // 2]) + + +# ------------------------------------------------------------------------------ +try: + from numpy import array, matrix, cos, sin + + has_numpy = True +except ImportError: + has_numpy = False + from math import cos, sin, pi + from .linalg import array, matrix + +# rand_ortho1 returns a numpy.array representing +# a random normalized n-dimension vector orthogonal to (1,1,1,...,1). +def rand_ortho1(n): + r = SystemRandom() + pos = [r.random() for x in range(n)] + s = sum(pos) + v = array(pos, dtype=float) - (s / float(n)) + norm = sqrt(sum(v * v)) + return v / norm + + +# ------------------------------------------------------------------------------ +# intersectC returns the intersection point between the Circle +# of radius r and centered on views' position with the line +# to the 'topt' point. +def intersectC(view, r, topt): + theta = getangle(view.xy, topt) + x = int(cos(theta) * r) + y = int(sin(theta) * r) + return (x, y) + + +# ------------------------------------------------------------------------------ +# setcurve returns the spline curve that path through the list of points P. +# The spline curve is a list of cubic bezier curves (nurbs) that have +# matching tangents at their extreme points. +# The method considered here is taken from "The NURBS book" (Les A. Piegl, +# Wayne Tiller, Springer, 1997) and implements a local interpolation rather +# than a global interpolation. +def setcurve(e, pts, tgs=None): + P = list(map(array, pts)) + n = len(P) + # tangent estimation + if tgs: + assert len(tgs) == n + T = list(map(array, tgs)) + Q = [P[k + 1] - P[k] for k in range(0, n - 1)] + else: + Q, T = tangents(P, n) + splines = [] + for k in range(n - 1): + t = T[k] + T[k + 1] + a = 16.0 - (t.dot(t)) + b = 12.0 * (Q[k].dot(t)) + c = -36.0 * Q[k].dot(Q[k]) + D = (b * b) - 4.0 * a * c + assert D >= 0 + sd = sqrt(D) + s1, s2 = (-b - sd) / (2.0 * a), (-b + sd) / (2.0 * a) + s = s2 + if s1 >= 0: + s = s1 + C0 = tuple(P[k]) + C1 = tuple(P[k] + (s / 3.0) * T[k]) + C2 = tuple(P[k + 1] - (s / 3.0) * T[k + 1]) + C3 = tuple(P[k + 1]) + splines.append([C0, C1, C2, C3]) + return splines + + +# ------------------------------------------------------------------------------ +def tangents(P, n): + assert n >= 2 + Q = [] + T = [] + for k in range(0, n - 1): + q = P[k + 1] - P[k] + t = q / sqrt(q.dot(q)) + Q.append(q) + T.append(t) + T.append(t) + return (Q, T) + + +# ------------------------------------------------------------------------------ +def setroundcorner(e, pts): + P = list(map(array, pts)) + n = len(P) + Q, T = tangents(P, n) + c0 = P[0] + t0 = T[0] + k0 = 0 + splines = [] + k = 1 + while k < n: + z = abs(t0[0] * T[k][1] - (t0[1] * T[k][0])) + if z < 1.0e-6: + k += 1 + continue + if (k - 1) > k0: + splines.append([c0, P[k - 1]]) + if (k + 1) < n: + splines.extend(setcurve(e, [P[k - 1], P[k + 1]], tgs=[T[k - 1], T[k + 1]])) + else: + splines.extend(setcurve(e, [P[k - 1], P[k]], tgs=[T[k - 1], T[k]])) + break + if (k + 2) < n: + c0 = P[k + 1] + t0 = T[k + 1] + k0 = k + 1 + k += 2 + else: + break + return splines or [[P[0], P[-1]]] + + +# ------------------------------------------------------------------------------ +def new_point_at_distance(pt, distance, angle): + # angle in radians + distance = float(distance) + x, y = pt[0], pt[1] + x += distance * cos(angle) + y += distance * sin(angle) + return x, y diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/linalg.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/linalg.py new file mode 100644 index 00000000000..6fa7e8a1696 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/linalg.py @@ -0,0 +1,319 @@ +from math import sqrt +from array import array as _array + +constants = (int, float) + + +def coerce_(types): + if types is None: + types = [] + if str in types: + raise TypeError + if complex in types: + raise TypeError + dtype = ("i", int) + if float in types: + dtype = ("d", float) + return dtype + + +def _mkslice(i, n): + if not isinstance(i, slice): + i = slice(i, i + 1, 1) + start, stop, stride = i.indices(n) + return slice(start, stop, stride) + + +def make_ij_slices(f): + def wrapper(self, ij, *args): + # I = slice(0,self.n,1) + J = slice(0, self.p, 1) + if isinstance(ij, tuple): + I = _mkslice(ij[0], self.n) + J = _mkslice(ij[1], self.p) + else: + I = _mkslice(ij, self.n) + return f(self, (I, J), *args) + + return wrapper + + +# minimalistic numpy.array replacement class used as fallback +# when numpy is not found in geometry module +class array(object): + def __init__(self, data, dtype=None, copy=True): + self.dim = len(data) + types = None + if self.dim > 0: + types = set([type(x) for x in data]) + if dtype is not None: + types = (dtype,) + tc, self.dtype = coerce_(types) + data = [self.dtype(x) for x in data] + if copy is True: + self.data = _array(tc, data) + else: + raise NotImplementedError + + def coerce(self, dtype): + data = [dtype(x) for x in self.data] + tc, dtype = coerce_((dtype,)) + self.data = _array(tc, data) + self.dtype = dtype + + @property + def typecode(self): + return self.data.typecode + + def __len__(self): + return self.dim + + def __str__(self): + s = " ".join(("%.12s" % x).ljust(12) for x in self) + return "[%s]" % s.strip() + + def copy(self): + return array(self.data, self.dtype) + + def __add__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x + y for (x, y) in zip(self.data, v.data)]) + + def __sub__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x - y for (x, y) in zip(self.data, v.data)]) + + def __neg__(self): + return array([-x for x in self.data], dtype=self.dtype) + + def __radd__(self, v): + return self + v + + def __rsub__(self, v): + return (-self) + v + + def dot(self, v): + assert v.dim == self.dim + return sum([x * y for (x, y) in zip(self.data, v.data)]) + + def __rmul__(self, k): + return array([k * x for x in self.data]) + + def __mul__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x * y for (x, y) in zip(self.data, v.data)]) + + def __truediv__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x / y for (x, y) in zip(self.data, v.data)]) + + __div__ = __truediv__ + + def __rtruediv__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x / y for (x, y) in zip(v.data, self.data)]) + + __rdiv__ = __rtruediv__ + + def __floordiv__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x // y for (x, y) in zip(self.data, v.data)]) + + def __rfloordiv__(self, v): + if isinstance(v, constants): + v = array([v] * self.dim) + assert v.dim == self.dim + return array([x // y for (x, y) in zip(v.data, self.data)]) + + def norm(self): + return sqrt(self.dot(self)) + + def max(self): + return max(self.data) + + def min(self): + return min(self.data) + + def __iter__(self): + for x in self.data: + yield x + + def __setitem__(self, i, v): + assert isinstance(i, int) + self.data[i] = self.dtype(v) + + def __getitem__(self, i): + i = _mkslice(i, self.dim) + res = self.data[i] + if len(res) == 1: + return res[0] + return array(res) + + def transpose(self): + return matrix(self.data, self.dtype) + + def __float__(self): + assert self.dim == 1 + return float(self.data[0]) + + +# ------------------------------------------------------------------------------ +# minimalistic numpy.matrix replacement class used as fallback +# when numpy is not found in geometry module +class matrix(object): + def __init__(self, data, dtype=None, copy=True, transpose=False): + # check input data types: + types = set([type(v) for v in data]) + if len(types) > 1: + raise TypeError + t = types.pop() + # import data: + if t in constants: + self.data = [array(data, dtype, copy)] + else: + if transpose: + data = zip(*data) + self.data = [array(v, dtype, copy) for v in data] + # define matrix sizes: + self.n = len(self.data) + sizes = set([len(v) for v in self.data]) + if len(sizes) > 1: + raise ValueError + self.p = sizes.pop() + if dtype is None: + # coerce types of arrays of matrix: + types = set([v.dtype for v in self.data]) + tc, dtype = coerce_(types) + for v in self.data: + v.coerce(dtype) + self.dtype = dtype + + def __len__(self): + return self.n * self.p + + def __str__(self): + s = "\n ".join([str(v) for v in self.data]) + return "[%s]" % s.strip() + + @property + def shape(self): + return (self.n, self.p) + + def lvecs(self): + return self.data + + def cvecs(self): + return [array(v, self.dtype) for v in zip(*self.data)] + + def copy(self): + return matrix(self.data, self.dtype) + + def transpose(self): + return matrix(self.data, dtype=self.dtype, transpose=True) + + def sum(self): + return sum([sum(v) for v in self.data]) + + @make_ij_slices + def __getitem__(self, ij): + I, J = ij + l = self.lvecs()[I] + m = matrix([v[J] for v in l]) + if m.n == 1: + v = m.data[0] + if v.dim == 1: + return v[0] + if len(l) > 1: + return v + return m + + @make_ij_slices + def __setitem__(self, ij, v): + I, J = ij + Ri = range(I.start, I.stop, I.step) + Rj = range(J.start, J.stop, J.step) + if type(v) in constants: + v = (v,) + value = (x for x in v) + for i in Ri: + for j in Rj: + self.data[i][j] = next(value) + + def __add__(self, m): + if isinstance(m, constants): + return matrix([u + m for u in self.data]) + else: + assert self.shape == m.shape + return matrix([u + v for (u, v) in zip(self.data, m.data)]) + + def __sub__(self, m): + if isinstance(m, constants): + return matrix([u - m for u in self.data]) + else: + assert self.shape == m.shape + return matrix([u - v for (u, v) in zip(self.data, m.data)]) + + def __neg__(self): + return matrix([-x for x in self.data], dtype=self.dtype) + + def __float__(self): + assert self.n == 1 and self.p == 1 + return self[0, 0] + + def __radd__(self, v): + return self + v + + def __rsub__(self, v): + return (-self) + v + + def __rmul__(self, k): + if not isinstance(k, constants): + raise TypeError + return matrix([k * v for v in self.data]) + + def __mul__(self, X): + if isinstance(X, constants): + return X * self + if isinstance(X, array): + assert X.dim == self.p + return array([v.dot(X) for v in self.data]) + if isinstance(X, matrix): + assert X.n == self.p + return matrix([self * v for v in X.cvecs()]) + + def __pow__(self, v): + S = [self] * v + assert len(S) > 0 + return reduce(lambda x, y: x * y, S) + + def __iter__(self): + for l in self.data: + for v in l: + yield v + + +class SimplexMin(object): + def __init__(self, A, b, c): + self.A = A + self.b = b + self.c = c + self.tableau() + + def tableau(self): + self.T = [] + + def setup(self): + self.enter = [] + delf.outer = [] diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/nx.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/nx.py new file mode 100644 index 00000000000..071a8c3c158 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/nx.py @@ -0,0 +1,39 @@ +# This code is part of Grandalf +# Copyright (C) 2014 Axel Tillequin (bdcht3@gmail.com) and others +# published under GPLv2 license or EPLv1 license +# Contributor(s): Fabio Zadrozny + +__all__ = [ + "convert_grandalf_graph_to_networkx_graph", + "convert_nextworkx_graph_to_grandalf", +] + +# Some utilities to interact with networkx. + +# Converts a grandalf graph to a networkx graph. +# Note that the edge concept is the same, but a vertex in grandalf is called a node in networkx. +def convert_grandalf_graph_to_networkx_graph(G): + from networkx import MultiDiGraph + + nxg = MultiDiGraph() + for v in G.V(): + nxg.add_node(v.data) + for e in G.E(): + nxg.add_edge(e.v[0].data, e.v[1].data) + return nxg + + +# Converts a networkx graph to a grandalf graph. +# Note that the edge concept is the same, but a vertex in grandalf is called a node in networkx. +def convert_nextworkx_graph_to_grandalf(G): + from ..graphs import Graph, Vertex, Edge + + V = [] + data_to_V = {} + for x in G.nodes(): + vertex = Vertex(x) + V.append(vertex) + data_to_V[x] = vertex + E = [Edge(data_to_V[xy[0]], data_to_V[xy[1]], data=xy) for xy in G.edges()] + g = Graph(V, E) + return g diff --git a/backends/qualcomm/utils/fx_viewer/grandalf/utils/poset.py b/backends/qualcomm/utils/fx_viewer/grandalf/utils/poset.py new file mode 100644 index 00000000000..31f69df4c4d --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/grandalf/utils/poset.py @@ -0,0 +1,155 @@ +# This code is part of Grandalf +# Copyright (C) 2008-2015 Axel Tillequin (bdcht3@gmail.com) and others +# published under GPLv2 license or EPLv1 license +# Contributor(s): Axel Tillequin + +from collections import OrderedDict + +__all__ = ["Poset"] + +# ------------------------------------------------------------------------------ +# Poset class implements a set but allows to interate over the elements in a +# deterministic way and to get specific objects in the set. +# Membership operator defaults to comparing __hash__ of objects but Poset +# allows to check for __cmp__/__eq__ membership by using contains__cmp__(obj) +class Poset(object): + def __init__(self, L): + self.o = OrderedDict() + for obj in L: + self.add(obj) + + def __repr__(self): + return "Poset(%r)" % (self.o,) + + def __str__(self): + f = "%%%dd" % len(str(len(self.o))) + s = [] + for i, x in enumerate(self.o.values()): + s.append(f % i + ".| %s" % repr(x)) + return "\n".join(s) + + def add(self, obj): + if obj in self: + return self.get(obj) + else: + self.o[obj] = obj + return obj + + def remove(self, obj): + if obj in self: + obj = self.get(obj) + del self.o[obj] + return obj + return None + + def index(self, obj): + return list(self.o.values()).index(obj) + + def get(self, obj): + return self.o.get(obj, None) + + def __getitem__(self, i): + return list(self.o.values())[i] + + def __len__(self): + return len(self.o) + + def __iter__(self): + for obj in iter(self.o.values()): + yield obj + + def __cmp__(self, other): + s1 = set(other.o.values()) + s2 = set(self.o.values()) + return cmp(s1, s2) + + def __eq__(self, other): + s1 = set(other.o.values()) + s2 = set(self.o.values()) + return s1 == s2 + + def __ne__(self, other): + s1 = set(other.o.values()) + s2 = set(self.o.values()) + return s1 != s2 + + def copy(self): + return Poset(self.o.values()) + + __copy__ = copy + + def deepcopy(self): + from copy import deepcopy + + L = deepcopy(self.o.values()) + return Poset(L) + + def __or__(self, other): + return self.union(other) + + def union(self, other): + p = Poset([]) + p.o.update(self.o) + p.o.update(other.o) + return p + + def update(self, other): + self.o.update(other.o) + + def __and__(self, other): + s1 = set(self.o.values()) + s2 = set(other.o.values()) + return Poset(s1.intersection(s2)) + + def intersection(self, *args): + p = self + for other in args: + p = p & other + return p + + def __xor__(self, other): + s1 = set(self.o.values()) + s2 = set(other.o.values()) + return Poset(s1.symmetric_difference(s2)) + + def symmetric_difference(self, *args): + p = self + for other in args: + p = p ^ other + return p + + def __sub__(self, other): + s1 = set(self.o.values()) + s2 = set(other.o.values()) + return Poset(s1.difference(s2)) + + def difference(self, *args): + p = self + for other in args: + p = p - other + return p + + def __contains__(self, obj): + return obj in self.o + + def contains__cmp__(self, obj): + return obj in self.o.values() + + def issubset(self, other): + s1 = set(self.o.values()) + s2 = set(other.o.values()) + return s1.issubset(s2) + + def issuperset(self, other): + s1 = set(self.o.values()) + s2 = set(other.o.values()) + return s1.issuperset(s2) + + __le__ = issubset + __ge__ = issuperset + + def __lt__(self, other): + return self <= other and len(self) != len(other) + + def __gt__(self, other): + return self >= other and len(self) != len(other) diff --git a/backends/qualcomm/utils/fx_viewer/models.py b/backends/qualcomm/utils/fx_viewer/models.py new file mode 100644 index 00000000000..38d3d603186 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/models.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class GraphNode: + """Wire-format node schema used by exporter and JSON payload.""" + + id: str + label: str = "" + x: float = 0.0 + y: float = 0.0 + width: float = 100.0 + height: float = 40.0 + info: dict[str, Any] = field(default_factory=dict) + tooltip: list[str] = field(default_factory=list) + fill_color: str | None = None + + +@dataclass +class GraphEdge: + """Wire-format edge schema used by exporter and JSON payload.""" + + v: str + w: str + points: list[dict[str, float]] = field(default_factory=list) + + +@dataclass +class BaseGraphPayload: + legend: list[dict[str, str]] + nodes: list[GraphNode] + edges: list[GraphEdge] + + +@dataclass +class GraphPayload: + base: BaseGraphPayload + extensions: dict[str, Any] diff --git a/backends/qualcomm/utils/fx_viewer/templates/README.md b/backends/qualcomm/utils/fx_viewer/templates/README.md new file mode 100644 index 00000000000..fe2d75fa75c --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/templates/README.md @@ -0,0 +1,128 @@ +# PyTorch FX Graph Viewer JS (Split Modules) + +## Description +This folder contains the split JavaScript runtime for the FX viewer used by `fx_viewer/exporter.py`. + +Current JS implementation provides a **standalone, embeddable, and highly interactive HTML5 Canvas viewer** for visualizing large-scale **PyTorch FX computational graphs**. +It is designed with a **modular architecture** to handle **thousands of nodes and edges** smoothly, without external dependencies. + +The viewer renders a graph payload with: +- main canvas graph renderer +- minimap +- search +- info panel +- extension layer toggles/color-by + +## User Interactions & UX Features + +- **Interactive Canvas** + Users can drag to pan and use the mouse wheel to zoom. + +- **Smart Minimap** + A collapsible right-sidebar minimap shows a bird’s-eye view of the entire graph. + Users can drag a viewport box within the minimap to pan. + +- **Selection Mode** + Clicking a node or edge isolates its execution flow. + Immediate inputs/outputs are highlighted while unrelated branches are dimmed. + +- **Info Panel** + Selecting or hovering over an element reveals PyTorch metadata + (tensor shape, dtype, target) in a scrollable panel with clickable links to jump to connected nodes. + +- **Fuzzy Search** + A robust search bar allows querying nodes by ID, op type, or meta attributes, + featuring keyboard navigation and instant camera teleportation. + +- **Theme Engine** + Seamless toggling between optimized **Light** and **Dark** mode palettes. + +- **Extensibility** + Supports custom overlays via the Python `GraphExtension` API. + Users can toggle data layers on/off and switch coloring modes via the Layers Menu. + + +## Files and Responsibilities +- `themes.js`: shared theme tokens (`THEMES`). +- `graph_data_store.js`: payload normalization, adjacency, active virtual-node composition. +- `search_engine.js`: fuzzy search over active nodes. +- `view_controller.js`: state machine and interaction orchestration. +- `canvas_renderer.js`: primary graph rendering + mouse interactions. +- `minimap_renderer.js`: minimap rendering + navigation. +- `ui_manager.js`: taskbar/search/layers/info panel/legend DOM. +- `fx_graph_viewer.js`: top-level facade (`FXGraphViewer`) and layout shell. + +## Script Load Order +Load in dependency order: + +1. `themes.js` +2. `graph_data_store.js` +3. `search_engine.js` +4. `view_controller.js` +5. `canvas_renderer.js` +6. `minimap_renderer.js` +7. `ui_manager.js` +8. `fx_graph_viewer.js` + +## Payload Contract +Expected input to `new FXGraphViewer(containerId, payload)`: + +```js +{ + base: { + legend: [{ label, color }], + nodes: [{ id, label, x, y, width, height, info, tooltip, fill_color? }], + edges: [{ v, w, points? }] + }, + extensions: { + [extId]: { + name: string, + legend: [{ label, color }], + nodes: { + [nodeId]: { + info?: object, + tooltip?: string[], + label_append?: string[], + fill_color?: string + } + } + } + } +} +``` + +## Public JS API +Primary API is on `FXGraphViewer`: + +- `new FXGraphViewer(containerId, payload)`: construct viewer in target container. +- `viewer.init()`: initialize thumbnail + first fit/position. +- `viewer.renderAll()`: force redraw canvas and minimap. +- `viewer.selectNode(nodeId)`: select + center on node. +- `viewer.search(query)`: run search and populate search UI. + +## Embedding Example (Split JS) +```html +
+ + + + + + + + + + +``` + +## Maintenance Notes +- Keep module boundaries strict: avoid cross-calling renderers from each other; route through `ViewerController`/`FXGraphViewer`. +- Prefer payload compatibility over UI-only changes; Python exporter and JS contract must stay aligned. +- If adding a new feature, document: + - state shape changes (`ViewerController.state`) + - payload schema changes (`GraphDataStore`) + - UX wiring (`UIManager` events) diff --git a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js new file mode 100644 index 00000000000..bc1543cf367 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js @@ -0,0 +1,617 @@ +/** + * ============================================================================ + * CLASS: CanvasRenderer + * ============================================================================ + * Handles the high-performance 2D Canvas rendering of the main graph and processes + * direct interactions (mouse, wheel) dynamically adapting to V3 Extensions. + * + * USE CASES & METHOD CALLS: + * - Lifecycle: Initialized by FXGraphViewer. It mounts a `` element into + * the DOM and sets up native event listeners. + * - Resizing: `resize()` is called by `window.onresize` or when the user drags the + * sidebar resizer. It recalculates physical canvas pixels to prevent blurring. + * - Painting: `render()` is called by `ViewerController` repeatedly during animations. + * + * RELATED VARIABLES & STATE: + * - `canvas`, `ctx`: The DOM element and 2D context. + * - `isDragging`, `dragMoved`: State flags to differentiate a "click" from a "drag" pan. + * - Interaction with GraphDataStore: Reads `store.activeNodes` (which are pre-merged by + * V3 Extensions) instead of raw base nodes. + * + * ALGORITHM & INFO FLOW: + * 1. Event Projections: Canvas events (mouse coordinates) are in "Screen Space". + * To figure out what node was hovered, `detectHover` reverse-projects the screen + * X/Y into "Graph Space" using the mathematical inverse of the camera transform: + * `graphX = (screenX - transform.x) / transform.k`. + * 2. Dynamic Color Overrides: Inside `render()`, it grabs the `node.fill_color` + * from the virtual node. If an Extension defines a color (e.g. Profiler Red), + * the renderer uses a custom `shadeColor()` algorithm to compute the hover, + * selection, and execution-path highlight colors programmatically. This ensures + * we don't wash out the user's custom heatmaps with generic UI blue. + * 3. Multi-line Node Rendering: Because V3 Extensions can inject multiple lines of + * text via `label_append`, the renderer centers the text block dynamically by + * calculating `startY = node.y - ((totalLines - 1) * lineHeight) / 2`. + * 4. Smart Tooltips: Calculates collision detection against the browser viewport bounds + * to ensure tooltips never render off-screen. It draws lines to connect the tooltip + * box to the physical node element. + * + * SCREEN COORDIATE vs. GRAPH COORDINATE: + * - The native DOM mouse events provide coordinates in "Screen Space" (pixels). + * - The nodes and edges in the `GraphDataStore` live in "Graph Space". + * - Pan/Zoom Mathematics (`transform.x`, `transform.y`, `transform.k`): + * - `transform.k` is the zoom scale. `transform.x` and `transform.y` are the + * pan offsets applied AFTER scaling. + * - To convert from Screen Space to Graph Space (e.g. for hover detection): + * `graphX = (screenX - transform.x) / transform.k` + * `graphY = (screenY - transform.y) / transform.k` + * - Device Pixel Ratio (`dpr`): High-res (retina) displays require the internal + * canvas pixel buffer to be multiplied by `dpr` to prevent blurring. + * The rendering loop begins by applying `ctx.scale(dpr, dpr)`, ensuring all + * subsequent logical drawing is natively scaled up for sharp text/lines. + * + * EVENT HANDLING & RENDERING: + * - `mousedown` / `mousemove` / `mouseup`: Calculates delta movements to directly + * add to `transform.x/y` for 1:1 mouse panning. + * - `wheel`: Calculates the zoom pivot point (the mouse cursor), applies an + * exponential scale factor (`zoomFactor`), and adjusts `transform.k` and + * `transform.x/y` simultaneously to zoom *into* the cursor. + * - `render()`: + * 1. Clears the canvas and fills the active theme background. + * 2. Applies `ctx.scale(dpr, dpr)` then `ctx.translate(transform.x, y)` and `ctx.scale(k, k)`. + * 3. Calculates dimming `opacity = 0.15` for nodes/edges outside the active selection. + * 4. Draws all edges (calculating midpoints for tooltips) and node rectangles. + * - `drawSmartTooltip()`: + * Calculates the boundary in Screen Space to prevent the tooltip from being drawn + * off-screen. It tests 4 positions around the hovered element, selects the shortest + * connecting line, and draws a dynamically scaled dashed line (`5 / transform.k`) + * so the dashes don't become massive when zoomed in. + * + * USER EXPERIENCE (UX): + * - Fluid, native-feeling pan and zoom centered directly on the mouse cursor. + * - Perfectly crisp text rendering on high-DPI screens. + * - Visual clutter reduction via intelligent dimming of unselected execution branches. + * - Semantic Highlighting: If a user colors nodes by Latency (Dark Red), clicking + * a node turns its dependencies slightly lighter red rather than resetting them + * to blue. This maintains analytical context even during deep navigation. + * - Infinite Canvas: The robust zoom/pan mathematics makes exploring 10,000+ node + * graphs feel native and hardware-accelerated. + * ============================================================================ + */ +class CanvasRenderer { + constructor(container, viewer) { + this.container = container; + this.viewer = viewer; + + this.canvasContainer = document.createElement('div'); + this.canvasContainer.style.width = '100%'; + this.canvasContainer.style.height = '100%'; + this.container.appendChild(this.canvasContainer); + + this.canvas = document.createElement('canvas'); + this.canvas.className = 'fx-canvas'; + this.canvasContainer.appendChild(this.canvas); + + this.ctx = this.canvas.getContext('2d'); + + this.isDragging = false; + this.lastMousePos = { x: 0, y: 0 }; + + this.resize(); + window.addEventListener('resize', () => this.resize()); + this.setupEvents(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.canvasContainer.getBoundingClientRect(); + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.viewer.renderAll(); + } + + setupEvents() { + this.canvas.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.dragMoved = false; + this.lastMousePos = { x: e.clientX, y: e.clientY }; + }); + + window.addEventListener('mousemove', (e) => { + if (this.isDragging) { + const dx = e.clientX - this.lastMousePos.x; + const dy = e.clientY - this.lastMousePos.y; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { + this.dragMoved = true; + } + this.viewer.controller.transform.x += dx; + this.viewer.controller.transform.y += dy; + this.lastMousePos = { x: e.clientX, y: e.clientY }; + this.viewer.renderAll(); + } else { + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const transform = this.viewer.controller.transform; + const graphX = (mouseX - transform.x) / transform.k; + const graphY = (mouseY - transform.y) / transform.k; + + this.detectHover(graphX, graphY); + } + }); + + window.addEventListener('mouseup', () => { + this.isDragging = false; + }); + + this.canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const zoomIntensity = 0.1; + const wheel = e.deltaY < 0 ? 1 : -1; + const zoomFactor = Math.exp(wheel * zoomIntensity); + + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + const transform = this.viewer.controller.transform; + const graphX = (mouseX - transform.x) / transform.k; + const graphY = (mouseY - transform.y) / transform.k; + + transform.k *= zoomFactor; + transform.x = mouseX - graphX * transform.k; + transform.y = mouseY - graphY * transform.k; + + this.viewer.renderAll(); + }, { passive: false }); + + this.canvas.addEventListener('click', (e) => { + if (this.dragMoved) return; + const state = this.viewer.controller.state; + this.viewer.controller.handleClick(state.hoveredNodeId, state.hoveredEdge); + }); + } + + detectHover(graphX, graphY) { + let nearestNode = null; + let nearestEdge = null; + + for (let i = 0; i < this.viewer.store.baseData.nodes.length; i++) { + const node = this.viewer.store.baseData.nodes[i]; + const w = node.width; + const h = node.height; + if (graphX >= node.x - w/2 && graphX <= node.x + w/2 && + graphY >= node.y - h/2 && graphY <= node.y + h/2) { + nearestNode = node.id; + break; + } + } + + if (!nearestNode) { + const transform = this.viewer.controller.transform; + const hoverDist = 5 / transform.k; + for (let i = 0; i < this.viewer.store.baseData.edges.length; i++) { + const edge = this.viewer.store.baseData.edges[i]; + if (!edge.bounds) continue; + if (graphX < edge.bounds.minX - hoverDist || graphX > edge.bounds.maxX + hoverDist || + graphY < edge.bounds.minY - hoverDist || graphY > edge.bounds.maxY + hoverDist) continue; + + const v = this.viewer.store.activeNodeMap.get(edge.v); + const w = this.viewer.store.activeNodeMap.get(edge.w); + let min_d = Infinity; + if (edge.points && edge.points.length > 0) { + for (let j = 0; j < edge.points.length - 1; j++) { + const d = this.distToSegment({x: graphX, y: graphY}, edge.points[j], edge.points[j+1]); + min_d = Math.min(min_d, d); + } + } else if (v && w) { + min_d = this.distToSegment({x: graphX, y: graphY}, v, w); + } + if (min_d <= hoverDist) { + nearestEdge = edge; + break; + } + } + } + + this.viewer.controller.handleHover(nearestNode, nearestEdge); + } + + distToSegment(p, v, w) { + const l2 = (v.x - w.x)**2 + (v.y - w.y)**2; + if (l2 === 0) return Math.hypot(p.x - v.x, p.y - v.y); + let t = ((p.x - v.x) * (w.x - v.x) + (p.y - v.y) * (w.y - v.y)) / l2; + t = Math.max(0, Math.min(1, t)); + return Math.hypot(p.x - (v.x + t * (w.x - v.x)), p.y - (v.y + t * (w.y - v.y))); + } + + render() { + const dpr = window.devicePixelRatio || 1; + const ctx = this.ctx; + const transform = this.viewer.controller.transform; + const state = this.viewer.controller.state; + const theme = THEMES[state.themeName]; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = theme.bg; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + + ctx.save(); + ctx.scale(dpr, dpr); + ctx.translate(transform.x, transform.y); + ctx.scale(transform.k, transform.k); + + const inNodes = new Set(); + const outNodes = new Set(); + + // nodes under selection or hovering + const activeNodes = [state.previewNodeId, state.selectedNodeId, state.hoveredNodeId] + activeNodes.forEach( + (activeNode) => { + (this.viewer.store.revAdjList.get(activeNode) || []).forEach(e => inNodes.add(e.v)); + (this.viewer.store.adjList.get(activeNode) || []).forEach(e => outNodes.add(e.w)); + } + ) + + const isSelectionMode = !!state.selectedNodeId || !!state.previewNodeId || !!state.selectedEdge; + + this.viewer.store.baseData.edges.forEach(edge => { + const v = this.viewer.store.activeNodeMap.get(edge.v); + const w = this.viewer.store.activeNodeMap.get(edge.w); + if (!v || !w) return; + + let opacity = 1.0; + if (isSelectionMode) { + const targetNode = state.previewNodeId || state.selectedNodeId; + const isSelectedNodeEdge = (targetNode && (edge.v === targetNode || edge.w === targetNode)) || state.selectedEdge === edge; + if (state.highlightAncestors) { + const inAncestors = state.ancestors.has(edge.v) && state.ancestors.has(edge.w); + const inDescendants = state.descendants.has(edge.v) && state.descendants.has(edge.w); + if (!inAncestors && !inDescendants && !isSelectedNodeEdge) { + opacity = 0.15; + } + } + // If highlightAncestors is false, opacity remains 1.0 for all edges + } + + const isHovered = state.hoveredEdge === edge || state.selectedEdge === edge; + const isInputEdge = activeNodes.includes(edge.w); + const isOutputEdge = activeNodes.includes(edge.v); + + if (isHovered) { + ctx.strokeStyle = theme.edgeHover; + ctx.globalAlpha = opacity; + ctx.lineWidth = 3; + } else if (isInputEdge) { + ctx.strokeStyle = theme.edgeInput; + ctx.globalAlpha = opacity; + ctx.lineWidth = 2; + } else if (isOutputEdge) { + ctx.strokeStyle = theme.edgeOutput; + ctx.globalAlpha = opacity; + ctx.lineWidth = 2; + } else { + ctx.strokeStyle = theme.edgeNormal; + ctx.globalAlpha = opacity; + ctx.lineWidth = 1; + } + + ctx.beginPath(); + let midX = 0, midY = 0; + if (edge.points && edge.points.length > 0) { + ctx.moveTo(edge.points[0].x, edge.points[0].y); + for (let i = 1; i < edge.points.length; i++) { + ctx.lineTo(edge.points[i].x, edge.points[i].y); + } + const midIdx = Math.floor(edge.points.length / 2); + midX = edge.points[midIdx].x; + midY = edge.points[midIdx].y; + if (edge.points.length % 2 === 0 && midIdx > 0) { + midX = (edge.points[midIdx].x + edge.points[midIdx-1].x) / 2; + midY = (edge.points[midIdx].y + edge.points[midIdx-1].y) / 2; + } + } else { + ctx.moveTo(v.x, v.y); + ctx.lineTo(w.x, w.y); + midX = (v.x + w.x) / 2; + midY = (v.y + w.y) / 2; + } + ctx.stroke(); + ctx.globalAlpha = 1.0; + + const srcNode = v; + if (srcNode && srcNode.info && srcNode.info.tensor_shape) { + let shapeStr = JSON.stringify(srcNode.info.tensor_shape).replace(/"/g, ''); + let dtypeStr = typeof srcNode.info.dtype === 'string' ? ` [${srcNode.info.dtype.replace('torch.', '')}]` : ''; + let label = `${shapeStr}${dtypeStr}`; + + ctx.font = '10px sans-serif'; + const tw = ctx.measureText(label).width; + const th = 12; + + ctx.globalAlpha = Math.max(opacity, 0.8); + ctx.fillStyle = theme.bg; + ctx.fillRect(midX - tw/2 - 2, midY - th/2 - 2, tw + 4, th + 4); + + ctx.fillStyle = isHovered ? theme.edgeHover : theme.textMuted; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, midX, midY); + ctx.globalAlpha = 1.0; + } + }); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + // Helper to lighten/darken hex colors dynamically based on active theme + const shadeColor = (color, percent) => { + if (!color || !color.startsWith('#')) return color; + let R = parseInt(color.substring(1,3), 16); + let G = parseInt(color.substring(3,5), 16); + let B = parseInt(color.substring(5,7), 16); + R = parseInt(R * (100 + percent) / 100); + G = parseInt(G * (100 + percent) / 100); + B = parseInt(B * (100 + percent) / 100); + R = (R<255)?R:255; G = (G<255)?G:255; B = (B<255)?B:255; + R = (R>0)?R:0; G = (G>0)?G:0; B = (B>0)?B:0; + const RR = ((R.toString(16).length==1)?"0"+R.toString(16):R.toString(16)); + const GG = ((G.toString(16).length==1)?"0"+G.toString(16):G.toString(16)); + const BB = ((B.toString(16).length==1)?"0"+B.toString(16):B.toString(16)); + return "#"+RR+GG+BB; + }; + + this.viewer.store.activeNodes.forEach(node => { + const isHovered = node.id === state.hoveredNodeId; + const isSelected = node.id === state.selectedNodeId; + const isPreview = node.id === state.previewNodeId; + const isInput = inNodes.has(node.id); + const isOutput = outNodes.has(node.id); + + let opacity = 1.0; + let isEdgeEndpoint = state.selectedEdge && (state.selectedEdge.v === node.id || state.selectedEdge.w === node.id); + + if (isSelectionMode) { + const targetNode = state.previewNodeId || state.selectedNodeId; + if (state.highlightAncestors) { + const isAncestors = state.ancestors.has(node.id); + const isDescendants = state.descendants.has(node.id); + if (!isAncestors && !isDescendants && node.id !== targetNode && !isEdgeEndpoint) { + opacity = 0.15; + } + } + } + + ctx.globalAlpha = opacity; + + // Determine base fill color (either custom extension color or theme default) + let baseColor = node.fill_color ? node.fill_color : theme.nodeFill; + + // Adjust lightness for Dark Mode to ensure custom colors aren't too bright + if (state.themeName === 'dark' && node.fill_color) { + baseColor = shadeColor(baseColor, -20); + } + + // Apply interaction state coloring dynamically instead of overriding with theme defaults + if (isSelected || isPreview || isEdgeEndpoint) { + ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 30 : 20); + ctx.globalAlpha = Math.max(opacity, 0.8); + } else if (isHovered) { + ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 20 : 20); + } else if (isInput) { + ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10); + } else if (isOutput) { + ctx.fillStyle = shadeColor(baseColor, state.themeName === 'dark' ? 10 : 10); + } else { + ctx.fillStyle = baseColor; + } + + ctx.fillRect(node.x - node.width/2, node.y - node.height/2, node.width, node.height); + + if (isSelected || isPreview || isHovered) { + ctx.strokeStyle = theme.edgeHover; + ctx.lineWidth = 2; + if (isHovered && !isSelected && !isPreview) { + ctx.setLineDash([5, 5]); + } else { + ctx.setLineDash([]); + } + ctx.strokeRect(node.x - node.width/2, node.y - node.height/2, node.width, node.height); + ctx.setLineDash([]); + } + + ctx.fillStyle = theme.text; + let allLines = [node.label || node.id]; + if (node.label_append && node.label_append.length > 0) { + allLines = allLines.concat(node.label_append); + } + + const lineHeight = 16; + const startY = node.y - ((allLines.length - 1) * lineHeight) / 2; + + for (let i = 0; i < allLines.length; i++) { + if (i === 0) ctx.font = 'bold 14px sans-serif'; + else ctx.font = '12px sans-serif'; + ctx.fillText(allLines[i], node.x, startY + (i * lineHeight)); + } + + ctx.globalAlpha = 1.0; + }); + + if (state.hoveredNodeId || state.hoveredEdge) { + this.drawSmartTooltip(ctx, state.hoveredNodeId, state.hoveredEdge); + } + + ctx.restore(); + } + + drawSmartTooltip(ctx, hoveredNodeId, hoveredEdge) { + const theme = THEMES[this.viewer.controller.state.themeName]; + let tooltipLines = []; + let groupNodes = []; + let targetX = 0; + let targetY = 0; + + if (hoveredNodeId) { + const node = this.viewer.store.activeNodeMap.get(hoveredNodeId); + if (!node) return; + targetX = node.x; + targetY = node.y; + groupNodes.push(node); + + (this.viewer.store.revAdjList.get(hoveredNodeId) || []).forEach(e => { + const n = this.viewer.store.activeNodeMap.get(e.v); + if (n) groupNodes.push(n); + }); + (this.viewer.store.adjList.get(hoveredNodeId) || []).forEach(e => { + const n = this.viewer.store.activeNodeMap.get(e.w); + if (n) groupNodes.push(n); + }); + + if (node.tooltip && node.tooltip.length > 0) { + tooltipLines.push(...node.tooltip); + } + } else if (hoveredEdge) { + const srcNode = this.viewer.store.activeNodeMap.get(hoveredEdge.v); + const dstNode = this.viewer.store.activeNodeMap.get(hoveredEdge.w); + if (!srcNode || !dstNode) return; + groupNodes.push(srcNode, dstNode); + + if (hoveredEdge.points && hoveredEdge.points.length > 0) { + const midIdx = Math.floor(hoveredEdge.points.length / 2); + if (hoveredEdge.points.length % 2 === 0 && midIdx > 0) { + targetX = (hoveredEdge.points[midIdx].x + hoveredEdge.points[midIdx-1].x) / 2; + targetY = (hoveredEdge.points[midIdx].y + hoveredEdge.points[midIdx-1].y) / 2; + } else { + targetX = hoveredEdge.points[midIdx].x; + targetY = hoveredEdge.points[midIdx].y; + } + } else { + targetX = (srcNode.x + dstNode.x) / 2; + targetY = (srcNode.y + dstNode.y) / 2; + } + + if (srcNode.info && srcNode.info.tensor_shape) { + tooltipLines.push(`Shape: ${JSON.stringify(srcNode.info.tensor_shape).replace(/"/g, '')}`); + } + if (srcNode.info && srcNode.info.dtype && typeof srcNode.info.dtype === "string") { + tooltipLines.push(`Dtype: ${srcNode.info.dtype.replace('torch.', '')}`); + } + } + + if (tooltipLines.length === 0 || groupNodes.length === 0) return; + + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + groupNodes.forEach(n => { + minX = Math.min(minX, n.x - n.width/2); + maxX = Math.max(maxX, n.x + n.width/2); + minY = Math.min(minY, n.y - n.height/2); + maxY = Math.max(maxY, n.y + n.height/2); + }); + + const transform = this.viewer.controller.transform; + const dpr = window.devicePixelRatio || 1; + + const fontSize = 12 / transform.k; + ctx.font = `bold ${fontSize}px sans-serif`; + let maxW = 0; + tooltipLines.forEach(line => { + maxW = Math.max(maxW, ctx.measureText(line).width); + }); + + const padding = 8 / transform.k; + const tw = maxW + padding * 2; + const lineHeight = 16 / transform.k; + const th = (tooltipLines.length * lineHeight) + padding * 2; + + const viewLeft = -transform.x / transform.k; + const viewTop = -transform.y / transform.k; + const viewRight = viewLeft + (this.canvas.width / dpr) / transform.k; + const viewBottom = viewTop + (this.canvas.height / dpr) / transform.k; + + const margin = 20 / transform.k; + + const candidates = [ + { id: 'up', x: targetX - tw/2, y: minY - margin - th }, + { id: 'left', x: minX - margin - tw, y: targetY - th/2 }, + { id: 'right', x: maxX + margin, y: targetY - th/2 }, + { id: 'down', x: targetX - tw/2, y: maxY + margin } + ]; + + let bestCand = null; + let minD = Infinity; + let rightCand = null; + + let validCandidates = candidates.filter(c => + c.x >= viewLeft && (c.x + tw) <= viewRight && + c.y >= viewTop && (c.y + th) <= viewBottom + ); + + if (validCandidates.length === 0) validCandidates = candidates; + + validCandidates.forEach(c => { + const cx = c.x + tw/2; + const cy = c.y + th/2; + const d = Math.hypot(cx - targetX, cy - targetY); + c.distance = d; + + if (c.id === 'right') { + rightCand = c; + } + + if (d < minD) { + minD = d; + bestCand = c; + } + }); + + if (rightCand && rightCand.distance <= minD * 10) { + bestCand = rightCand; + } + + let tooltipX = bestCand.x; + let tooltipY = bestCand.y; + + let lineStartX = targetX; + let lineStartY = targetY; + if (hoveredNodeId) { + const node = this.viewer.store.activeNodeMap.get(hoveredNodeId); + if (node) { + if (bestCand.id === 'right') lineStartX = node.x + node.width / 2; + else if (bestCand.id === 'left') lineStartX = node.x - node.width / 2; + else if (bestCand.id === 'up') lineStartY = node.y - node.height / 2; + else if (bestCand.id === 'down') lineStartY = node.y + node.height / 2; + } + } + + ctx.strokeStyle = theme.edgeHover; + ctx.lineWidth = 2 / transform.k; + ctx.setLineDash([5 / transform.k, 5 / transform.k]); + ctx.beginPath(); + ctx.moveTo(lineStartX, lineStartY); + if (bestCand.id === 'up') { + ctx.lineTo(tooltipX + tw/2, tooltipY + th); + } else if (bestCand.id === 'down') { + ctx.lineTo(tooltipX + tw/2, tooltipY); + } else if (bestCand.id === 'left') { + ctx.lineTo(tooltipX + tw, tooltipY + th/2); + } else { // right + ctx.lineTo(tooltipX, tooltipY + th/2); + } + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = theme.uiBg; + ctx.fillRect(tooltipX, tooltipY, tw, th); + ctx.strokeStyle = theme.uiBorder; + ctx.lineWidth = 1 / transform.k; + ctx.strokeRect(tooltipX, tooltipY, tw, th); + + ctx.fillStyle = theme.text; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + tooltipLines.forEach((line, idx) => { + ctx.fillText(line, tooltipX + padding, tooltipY + padding + idx * lineHeight); + }); + } +} diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js new file mode 100644 index 00000000000..7b8432e3ec4 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -0,0 +1,233 @@ +/** + * ============================================================================ + * CLASS: FXGraphViewer (The Application Facade) + * ============================================================================ + * This class is the top-level orchestration layer for the entire application. + * It is responsible for DOM generation, module initialization, layout resizing, + * and exposing the public API. + * + * USE CASES & METHOD CALLS: + * 1. Instantiation: `const viewer = new FXGraphViewer('container-id', json_data);` + * 2. Initialization: `viewer.init();` -> triggers initial drawing and zoom. + * 3. External Control: `viewer.selectNode('node_name');` -> pans and highlights a node. + * 4. External Control: `viewer.search('query');` -> fills the search bar and executes search. + * 5. Re-rendering: `viewer.renderAll();` -> triggers a paint cycle on all canvases. + * + * RELATED VARIABLES & STATE: + * - `containerId`: String ID of the host HTML element. + * - `wrapper`, `mainArea`, `sidebar`, `resizer`, `resizerH`: Dynamic HTML DOM + * elements created to establish the layout. + * - `store`, `searchEngine`, `controller`: Core logical sub-modules. + * - `canvasRenderer`, `minimapRenderer`, `ui`: Pure view sub-modules. + * + * INFO FLOW & ALGORITHMS: + * - DOM Building (constructor): It begins by injecting a heavy ` + + +
+
+ fx_viewer Unified API Harness + + + + +
+
+ +
+
+
+
HTML Input (Editable)
+
JS API Input (Editable)
+
Run Log
+
+ +
+
Outcome (Resizable Host)
+
+
+
+
+ + + + + + diff --git a/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py b/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py new file mode 100644 index 00000000000..5a7f6dfea62 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py @@ -0,0 +1,332 @@ +"""Testcase catalog for the unified fx_viewer API harness. + +This file keeps testcase definitions separate from payload generation so we can: +1) Reuse the same UI harness template. +2) Reuse the same testcase list across portable and Qualcomm profiles. +3) Keep JS snippets educational and easy to evolve. +""" + +from __future__ import annotations + +from typing import Any + + +def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: + cases: list[dict[str, Any]] = [ + { + "id": "topology_split", + "title": "Topology + Type Layers (Split)", + "description": "Baseline split layout. Demonstrates setLayers/setColorBy + legend updates.", + "html": """ +
+""".strip(), + "js": """ +// Educational: create a standard split viewer and activate two extension layers. +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#case_view' }, + layout: { preset: 'split' }, + state: { theme: 'light' }, +}); +viewer.init(); + +// Show both structural layers, then color by topological order. +viewer.setLayers(['color_by_type', 'topological_order']); +viewer.setColorBy('topological_order'); + +api.registerViewer(viewer); +api.log('Loaded structural payload with color_by_type + topological_order'); +""".strip(), + }, + { + "id": "headless_slots", + "title": "Headless Slots + Dynamic Slider", + "description": "Demonstrates mount.slots ownership + patchLayerNodes for dynamic recoloring.", + "html": """ +
+
+
+
+
Custom Controls
+ +
+
+
+
+
+
+
+
+
+
+
+
+""".strip(), + "js": """ +// Educational: in headless mode we mount viewer shell to a hidden root, +// and dock renderers into external slots controlled by host HTML. +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { + root: '#headless_mount', + slots: { + canvas: '#slot_canvas', + info: '#slot_info', + minimap: '#slot_minimap', + legend: '#slot_legend', + }, + }, + layout: { + preset: 'headless', + panels: { + minimap: { visible: true, height: 220, resizable: false }, + info: { visible: true }, + legend: { visible: true }, + }, + }, + ui: { + controls: { + toolbar: false, + search: false, + layers: false, + colorBy: false, + theme: false, + legend: true, + zoomButtons: false, + clearButton: false, + highlightButton: false, + fullscreenButton: false, + }, + }, + state: { activeExtensions: ['topological_order'], colorBy: 'topological_order' }, +}); +viewer.init(); +api.registerViewer(viewer); + +// Educational: dynamic patch by node id + re-apply colorBy. +const slider = document.getElementById('topo_threshold'); +const valueEl = document.getElementById('topo_threshold_value'); +const nodes = viewer.store.extensions['topological_order'].nodes; +const maxTopo = Math.max(...Object.values(nodes).map(n => Number(n.info.topo_index || 0))); +slider.max = String(maxTopo); + +function renderThreshold() { + const threshold = Number(slider.value); + valueEl.textContent = `threshold=${threshold} / ${maxTopo}`; + const patch = {}; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const idx = Number(nodeData.info.topo_index || 0); + patch[nodeId] = { fill_color: idx >= threshold ? '#b91c1c' : '#93c5fd' }; + }); + viewer.patchLayerNodes('topological_order', patch); + viewer.setColorBy('topological_order'); +} + +slider.addEventListener('input', renderThreshold); +renderThreshold(); +api.log('Headless slot composition active. Move slider and inspect recoloring.'); +""".strip(), + }, + { + "id": "accuracy_dynamic", + "title": "Per-layer Accuracy Controls", + "description": "Real per-layer accuracy payload with dynamic threshold + theme sync.", + "html": """ +
+
+
Accuracy Controls
+ +
+ +
+ +
+
+
+""".strip(), + "js": """ +// Educational: this payload includes real per-layer accuracy metrics from capture outputs. +const viewer = FXGraphViewer.create({ + payload: api.payloads.accuracy_candidate, + mount: { root: '#acc_view' }, + layout: { preset: 'split', panels: { sidebar: { width: 420 } } }, + state: { + activeExtensions: ['per_layer_accuracy', 'topological_order', 'color_by_type'], + colorBy: 'per_layer_accuracy', + theme: 'light', + }, +}); +viewer.init(); +api.registerViewer(viewer); + +const extId = 'per_layer_accuracy'; +const nodes = viewer.store.extensions[extId].nodes; +const severities = Object.values(nodes) + .map(n => Number((n.info && n.info.severity_score) || 0)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + +const slider = document.getElementById('acc_threshold'); +const label = document.getElementById('acc_threshold_value'); +const themeSel = document.getElementById('acc_theme'); +const focusBtn = document.getElementById('acc_focus_worst'); + +function quantile(q) { + if (severities.length === 0) return 0; + const i = Math.min(severities.length - 1, Math.max(0, Math.floor(q * (severities.length - 1)))); + return severities[i]; +} + +function applyThreshold() { + const p = Number(slider.value) / 100; + const threshold = quantile(p); + label.textContent = `percentile=${slider.value}, threshold=${threshold.toExponential(3)}`; + const patch = {}; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const s = Number((nodeData.info && nodeData.info.severity_score) || 0); + patch[nodeId] = { + fill_color: s >= threshold ? '#991b1b' : '#fecaca', + label_append: [`sev=${s.toExponential(2)}`], + }; + }); + viewer.patchLayerNodes(extId, patch); + viewer.setColorBy(extId); +} + +slider.addEventListener('input', applyThreshold); +themeSel.addEventListener('change', () => viewer.setTheme(themeSel.value)); +focusBtn.addEventListener('click', () => { + let worst = null; + let worstScore = -Infinity; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const s = Number((nodeData.info && nodeData.info.severity_score) || 0); + if (s > worstScore) { + worstScore = s; + worst = nodeId; + } + }); + if (worst) viewer.selectNode(worst, { animate: true, center: true }); +}); + +applyThreshold(); +api.log(`Loaded real accuracy payload. worst_sample_index=${api.payloads.meta.worst_sample_index}`); +""".strip(), + }, + { + "id": "fullscreen_toolbar", + "title": "Fullscreen Button API", + "description": "Taskbar fullscreen button (layout.fullscreen.button) + programmatic fullscreen API.", + "html": """ +
+
+
Fullscreen Controls
+ + +

Taskbar also has a fullscreen toggle button in this case.

+
+
+
+""".strip(), + "js": """ +// Educational: fullscreen button is enabled by layout.fullscreen.button. +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#fs_view' }, + layout: { preset: 'split', fullscreen: { enabled: true, button: true } }, + state: { activeExtensions: ['color_by_type'], colorBy: 'color_by_type' }, +}); +viewer.init(); +api.registerViewer(viewer); + +document.getElementById('api_enter_fs').addEventListener('click', () => viewer.enterFullscreen()); +document.getElementById('api_exit_fs').addEventListener('click', () => viewer.exitFullscreen()); + +api.log('Use taskbar fullscreen button or side controls to validate API + UI integration.'); +""".strip(), + }, + { + "id": "compare_sync", + "title": "Compare + Sync", + "description": "Native FXGraphCompare orchestration with sync and compact toggles.", + "html": """ +
+ + +
+
+
+
+
+""".strip(), + "js": """ +const left = FXGraphViewer.create({ + payload: api.payloads.accuracy_reference, + mount: { root: '#cmp_left' }, + layout: { preset: 'split' }, +}); +left.init(); +api.registerViewer(left); + +const right = FXGraphViewer.create({ + payload: api.payloads.accuracy_candidate, + mount: { root: '#cmp_right' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['per_layer_accuracy'], colorBy: 'per_layer_accuracy' }, +}); +right.init(); +api.registerViewer(right); + +const compare = FXGraphCompare.create({ + viewers: [left, right], + layout: { columns: 2, compact: true, container: '#cmp_grid' }, + sync: { selection: true }, +}); +api.registerCompare(compare); + +document.getElementById('cmp_sync').addEventListener('change', (e) => { + compare.setSync({ selection: e.target.checked }); +}); + +document.getElementById('cmp_compact').addEventListener('change', (e) => { + compare.setCompact(e.target.checked); +}); + +api.log('Select nodes in either pane to verify synced focus behavior.'); +""".strip(), + }, + ] + + if include_qualcomm: + cases.append( + { + "id": "qualcomm_metadata", + "title": "Qualcomm PTQ Metadata", + "description": "Qualcomm-specific payload metadata from real QNN PTQ path.", + "html": """ +
+
+

Qualcomm Metadata

+

+  
+
+
+""".strip(), + "js": """ +const viewer = FXGraphViewer.create({ + payload: api.payloads.accuracy_candidate, + mount: { root: '#qnn_view' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['per_layer_accuracy'], colorBy: 'per_layer_accuracy' }, +}); +viewer.init(); +api.registerViewer(viewer); + +document.getElementById('qnn_meta').textContent = JSON.stringify(api.payloads.meta, null, 2); +api.log('Rendered Qualcomm PTQ payload + metadata snapshot.'); +""".strip(), + } + ) + + return cases From 55e69f115b24716fe463669ce269217d08a3bd8b Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 19:15:13 +0800 Subject: [PATCH 05/65] fx_viewer/examples: add per-layer accuracy demo and observatory callsite fix --- .../examples/PER_LAYER_ACCURACY_DEMO_NOTES.md | 150 +++ .../examples/demo_fx_viewer_extensions.py | 16 +- .../examples/demo_per_layer_accuracy_fx.py | 948 ++++++++++++++++++ 3 files changed, 1104 insertions(+), 10 deletions(-) create mode 100644 backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md create mode 100644 backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py diff --git a/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md b/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md new file mode 100644 index 00000000000..66abbcbbb4e --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md @@ -0,0 +1,150 @@ +# FX Viewer Per-Layer Accuracy Demo Notes + +## Goal +Build a standalone (`fx_viewer`-only, no Observatory UI) demo that: +1. Captures per-layer outputs from two FX graphs. +2. Matches layers across stages using debug handles. +3. Computes per-layer accuracy metrics. +4. Visualizes severity on graph nodes (red = worse). +5. Supports backend-agnostic and Qualcomm PTQ workflows. +6. Uses worst-sample-first debugging workflow. + +## Implemented Files +- Demo script: `backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py` +- Compile-flow fix: `exir/program/_program.py` + +## Compile-Only Fix Applied +### Issue +`examples.qualcomm.oss_scripts.swin_transformer --compile_only` failed with: +- `AttributeError: ...debugging_utils.observatory has no attribute 'collect'` + +### Root cause +`exir/program/_program.py` imported the module and called `observatory.collect(...)` instead of class API `Observatory.collect(...)`. + +### Patch +In `exir/program/_program.py`, changed: +- `from ... import observatory; observatory.collect(...)` + +To: +- `from ... import Observatory; Observatory.collect(...)` + +### Result +The compile-only command now completes successfully. + +## fx_viewer Import Path Decision +Use canonical import first: +- `from executorch.backends.qualcomm.utils.fx_viewer import ...` + +Fallback to local package path only when Qualcomm init is unavailable: +- add `backends/qualcomm/utils` to `sys.path` +- import `fx_viewer` + +This keeps Qualcomm path clean while preserving backend-agnostic usability. + +## Demo Improvements Implemented + +### 1) Split-view UX + n-split-capable layout +`compare_side_by_side.html` is now generated by a generic multi-panel renderer: +- CSS grid supports N panels. +- Toolbar includes: + - sync selection toggle + - compact mode toggle (hides each embedded viewer sidebar for split mode) + - columns selector (1-4) + +### 2) Smooth transition on synced node selection +When sync is enabled and a node is selected in one panel: +- other panels call `controller.selectNode(nodeId)` +- then `controller.animateToNode(nodeId)` + +This uses fx_viewer’s smooth camera transition API. + +### 3) Worst-sample-first per-layer debug workflow +Per pipeline: +1. Run E2E output comparison on multiple samples (`--num-samples`). +2. Compute sample drop score from output error/cosine. +3. Pick worst sample index. +4. Capture per-layer intermediates only on that worst sample. + +This aligns per-layer analysis with the most obvious end-to-end degradation case. + +### 4) Severity-based coloring (red bad, lighter okay) +Per-layer severity is computed as: +- `severity_score = max_abs_err + max(0, 1 - cosine_similarity)` + +Graph extension uses: +- `NumericColorRule(attribute="severity_score", cmap="reds")` + +So darker/stronger red means larger performance drop. + +## Debug Handle + Matching Behavior +### APIs used +- `devtools/inspector/_intermediate_output_capturer.py` +- `devtools/inspector/_inspector_utils.py:get_aot_debug_handle_to_op_name_mapping` +- `exir/passes/debug_handle_generator_pass.py:generate_missing_debug_handles` + +### Notes +- Capturer keys outputs by debug-handle tuples. +- For transformed `GraphModule` (not `ExportedProgram`), script ensures handles with `_ensure_graph_module_debug_handles(...)`. + +### Matching strategy +1. Exact debug-handle intersection. +2. Fallback by node name. + +## Validation Runs + +## 1) Backend-agnostic demo +```bash +source .venv/bin/activate +export PYTHONPATH=~/ +python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ + --model toy --pipeline fake_quant --num-samples 6 --output-dir /tmp/fx_demo_toy2 +``` +Result: success. + +## 2) Qualcomm PTQ demo +```bash +source qairt/2.37.0.250724/bin/envsetup.sh +source .venv/bin/activate +export PYTHONPATH=~/ +python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ + --model toy --pipeline qualcomm_ptq --soc-model SM8650 --backend htp \ + --num-samples 6 --calibration-steps 2 --output-dir /tmp/fx_demo_qnn_toy2 +``` +Result: success. + +## 3) Requested compile-only Swin command (after fix) +```bash +source qairt/2.37.0.250724/bin/envsetup.sh +source .venv/bin/activate +export PYTHONPATH=~/ +python -m examples.qualcomm.oss_scripts.swin_transformer -m SM8650 -b ./build-android \ + --dataset imagenet-mini-val/val/ -H mlgtw-linux -s bebcca9b -a swin_transformer_Lanai \ + --seed 1126 --compile_only +``` +Result: success (no `observatory.collect` attribute error). + +## ETRecord Graph Save Summary +From `devtools/etrecord/_etrecord.py`, ETRecord stores: +- edge dialect exported program(s) +- optional exported program +- optional graph map +- debug handle map +- delegate map +- instruction-to-num-outs map +- optional reference outputs and representative inputs +- export graph id + +This is sufficient for future multi-stage replay/compare workflows. + +## Where to Insert Future Graph Capture (Qualcomm compile path) +In `examples/qualcomm/utils.py:build_executorch_binary`, natural insertion points are: +1. post-export (float FX graph) +2. post-`convert_pt2e` (quantized FX graph) +3. pre/post `to_edge_transform_and_lower_to_qnn` (edge transform stages) + +For this demo, scope is intentionally FX graphs only. + +## Current Limitations +- Sample generation is random tensor-based (no dataset loader in this script yet). +- Matching fallback is node-name only (no topology matcher yet). +- Compare page is N-panel capable, but current pipeline exports 2 primary panels by default. diff --git a/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py index 37bcae95362..d1ef5dc059b 100644 --- a/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py +++ b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py @@ -17,20 +17,16 @@ from collections import deque from pathlib import Path from typing import Any -import sys import torch -REPO_ROOT = Path(__file__).resolve().parents[1] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) -from executorch.backends.qualcomm.debugger.fx_viewer import ( - CategoricalColorRule, - FXGraphExporter, - GraphExtension, - GraphNode, - NumericColorRule, +from executorch.backends.qualcomm.utils.fx_viewer import ( + CategoricalColorRule, + FXGraphExporter, + GraphExtension, + GraphNode, + NumericColorRule, ) diff --git a/backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py b/backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py new file mode 100644 index 00000000000..4d06fd50397 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py @@ -0,0 +1,948 @@ +#!/usr/bin/env python3 +"""Standalone fx_viewer per-layer accuracy demo (no Observatory UI). + +This demo compares two FX graphs and visualizes per-layer accuracy deltas using +an fx_viewer extension. It supports two pipelines: +- fake_quant: backend-agnostic simulated quantization (weight rounding only) +- qualcomm_ptq: Qualcomm PTQ path using QnnQuantizer + prepare/convert PT2E + +It also follows the debug workflow: +1) Run end-to-end on multiple input samples. +2) Pick the worst sample by output drop score. +3) Capture per-layer outputs only on that worst sample. + +Run from repo root: + source .venv/bin/activate + export PYTHONPATH=~/ + + # Backend-agnostic demo: + python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ + --pipeline fake_quant --model swin + + # Qualcomm PTQ demo (requires QNN/QAIRT env): + source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh + python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ + --pipeline qualcomm_ptq --model swin --soc-model SM8650 +""" + +from __future__ import annotations + +import argparse +import copy +import json +import math +import os +import random +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, Mapping, Sequence + +import torch + + +from executorch.devtools.inspector._inspector_utils import ( # noqa: E402 + DebugHandle, + get_aot_debug_handle_to_op_name_mapping, +) +from executorch.devtools.inspector._intermediate_output_capturer import ( # noqa: E402 + IntermediateOutputCapturer, +) +from executorch.exir.passes.debug_handle_generator_pass import ( # noqa: E402 + generate_missing_debug_handles, +) + +from executorch.backends.qualcomm.utils.fx_viewer import ( # noqa: E402 + FXGraphExporter, + GraphExtension, + NumericColorRule, +) + + +@dataclass +class MatchRecord: + candidate_node: str + reference_node: str + candidate_debug_handle: DebugHandle + reference_debug_handle: DebugHandle + matched_by: str + + +@dataclass +class LayerMetric: + candidate_node: str + reference_node: str + candidate_debug_handle: DebugHandle + reference_debug_handle: DebugHandle + matched_by: str + numel_compared: int + candidate_shape: str + reference_shape: str + max_abs_err: float + mean_abs_err: float + mse: float + cosine_similarity: float + severity_score: float + + +@dataclass +class SampleScore: + sample_index: int + mse: float + max_abs_err: float + cosine_similarity: float + drop_score: float + + +@dataclass +class GraphPair: + pipeline: str + reference_name: str + candidate_name: str + reference_graph: torch.fx.GraphModule + candidate_graph: torch.fx.GraphModule + metadata: dict[str, Any] + + +def _set_seed(seed: int) -> None: + random.seed(seed) + torch.manual_seed(seed) + + +def _patch_swin_window_ops() -> None: + # Mirrors examples/qualcomm/oss_scripts/swin_transformer.py adjustments. + from transformers.models.swin import modeling_swin + + def window_partition(input_feature: torch.Tensor, window_size: int) -> torch.Tensor: + batch_size, height, width, num_channels = input_feature.shape + input_feature = input_feature.view( + batch_size, + height // window_size, + window_size, + width // window_size, + window_size * num_channels, + ) + windows = input_feature.permute(0, 1, 3, 2, 4).contiguous() + return windows.view(-1, window_size, window_size, num_channels) + + def window_reverse( + windows: torch.Tensor, window_size: int, height: int, width: int + ) -> torch.Tensor: + num_channels = windows.shape[-1] + windows = windows.view( + -1, + height // window_size, + width // window_size, + window_size, + window_size * num_channels, + ) + windows = windows.permute(0, 1, 3, 2, 4).contiguous() + return windows.view(-1, height, width, num_channels) + + modeling_swin.window_partition = window_partition + modeling_swin.window_reverse = window_reverse + + +def _build_swin_model() -> tuple[torch.nn.Module, tuple[int, ...]]: + from transformers import SwinConfig, SwinForImageClassification + + _patch_swin_window_ops() + config = SwinConfig( + image_size=224, + patch_size=4, + num_channels=3, + embed_dim=64, + depths=[1, 1, 1, 1], + num_heads=[2, 4, 8, 16], + window_size=7, + num_labels=10, + ) + model = SwinForImageClassification(config).eval().to("cpu") + return model, (1, 3, 224, 224) + + +class _ToyModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.features = torch.nn.Sequential( + torch.nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=1), + torch.nn.ReLU(), + torch.nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1), + torch.nn.GELU(), + torch.nn.AdaptiveAvgPool2d((1, 1)), + ) + self.classifier = torch.nn.Linear(32, 10) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.features(x) + x = torch.flatten(x, 1) + return self.classifier(x) + + +def _build_toy_model() -> tuple[torch.nn.Module, tuple[int, ...]]: + return _ToyModel().eval().to("cpu"), (1, 3, 128, 128) + + +def _make_random_samples(input_shape: tuple[int, ...], num_samples: int) -> list[tuple[torch.Tensor, ...]]: + samples: list[tuple[torch.Tensor, ...]] = [] + for _ in range(num_samples): + samples.append((torch.rand(*input_shape),)) + return samples + + +def _fake_quantize_tensor(tensor: torch.Tensor, num_bits: int = 8) -> torch.Tensor: + if tensor.numel() == 0: + return tensor + qmax = (1 << (num_bits - 1)) - 1 + max_abs = tensor.detach().abs().max() + if float(max_abs) == 0.0: + return tensor + scale = max_abs / float(qmax) + q = (tensor / scale).round().clamp(-qmax, qmax) + return q * scale + + +def _make_fake_quantized_copy(model: torch.nn.Module) -> torch.nn.Module: + quantized = copy.deepcopy(model) + with torch.no_grad(): + for parameter in quantized.parameters(): + parameter.copy_(_fake_quantize_tensor(parameter)) + return quantized.eval().to("cpu") + + +def _export_with_debug_handles( + model: torch.nn.Module, sample_inputs: tuple[torch.Tensor, ...] +) -> torch.export.ExportedProgram: + ep = torch.export.export(model, sample_inputs, strict=False) + generate_missing_debug_handles(ep) + return ep + + +def _capture_outputs( + graph_module: torch.fx.GraphModule, sample_inputs: tuple[torch.Tensor, ...] +) -> Dict[DebugHandle, Any]: + capturer = IntermediateOutputCapturer(graph_module) + return capturer.run_and_capture(*sample_inputs) + + +def _node_to_handle( + handle_to_nodes: Mapping[DebugHandle, Sequence[str]], +) -> Dict[str, DebugHandle]: + result: Dict[str, DebugHandle] = {} + for handle, names in handle_to_nodes.items(): + for name in names: + result[name] = handle + return result + + +def _ensure_graph_module_debug_handles(graph_module: torch.fx.GraphModule) -> None: + max_handle = 0 + for node in graph_module.graph.nodes: + handle = node.meta.get("debug_handle") + if isinstance(handle, int): + max_handle = max(max_handle, handle) + elif isinstance(handle, (tuple, list)): + numeric = [int(x) for x in handle if isinstance(x, int)] + if numeric: + max_handle = max(max_handle, max(numeric)) + + next_handle = max_handle + 1 + for node in graph_module.graph.nodes: + if node.op in ("placeholder", "output"): + continue + handle = node.meta.get("debug_handle") + missing = handle is None or handle == 0 or handle == () or handle == [] + if missing: + node.meta["debug_handle"] = next_handle + next_handle += 1 + + +def _match_nodes( + reference_map: Mapping[DebugHandle, Sequence[str]], + candidate_map: Mapping[DebugHandle, Sequence[str]], +) -> tuple[list[MatchRecord], dict[str, int]]: + matches: list[MatchRecord] = [] + + ref_node_to_handle = _node_to_handle(reference_map) + cand_node_to_handle = _node_to_handle(candidate_map) + + matched_candidate_nodes: set[str] = set() + + # Phase 1: exact debug-handle matching. + for handle in sorted(set(reference_map.keys()) & set(candidate_map.keys())): + reference_node = reference_map[handle][0] + for candidate_node in candidate_map[handle]: + matches.append( + MatchRecord( + candidate_node=candidate_node, + reference_node=reference_node, + candidate_debug_handle=handle, + reference_debug_handle=handle, + matched_by="debug_handle", + ) + ) + matched_candidate_nodes.add(candidate_node) + + # Phase 2: node-name fallback. + for candidate_node, candidate_handle in cand_node_to_handle.items(): + if candidate_node in matched_candidate_nodes: + continue + if candidate_node not in ref_node_to_handle: + continue + reference_handle = ref_node_to_handle[candidate_node] + matches.append( + MatchRecord( + candidate_node=candidate_node, + reference_node=candidate_node, + candidate_debug_handle=candidate_handle, + reference_debug_handle=reference_handle, + matched_by="node_name", + ) + ) + matched_candidate_nodes.add(candidate_node) + + stats = { + "reference_handles": len(reference_map), + "candidate_handles": len(candidate_map), + "handle_intersection": len(set(reference_map.keys()) & set(candidate_map.keys())), + "matched_nodes": len(matches), + "matched_by_debug_handle": sum(1 for m in matches if m.matched_by == "debug_handle"), + "matched_by_node_name": sum(1 for m in matches if m.matched_by == "node_name"), + "candidate_nodes_unmatched": max(0, len(cand_node_to_handle) - len(matched_candidate_nodes)), + } + return matches, stats + + +def _flatten_for_metric(value: Any) -> tuple[torch.Tensor | None, str]: + if isinstance(value, torch.Tensor): + return value.detach().cpu().to(torch.float64).reshape(-1), str(tuple(value.shape)) + + if isinstance(value, (tuple, list)): + tensor_parts = [ + v.detach().cpu().to(torch.float64).reshape(-1) + for v in value + if isinstance(v, torch.Tensor) + ] + if tensor_parts: + shape = "[" + ", ".join(str(tuple(v.shape)) for v in value if isinstance(v, torch.Tensor)) + "]" + return torch.cat(tensor_parts), shape + scalar_parts = [float(v) for v in value if isinstance(v, (int, float))] + if scalar_parts: + return torch.tensor(scalar_parts, dtype=torch.float64), f"list(len={len(scalar_parts)})" + return None, "unsupported_sequence" + + if isinstance(value, (int, float, bool)): + return torch.tensor([float(value)], dtype=torch.float64), "scalar" + + return None, f"unsupported:{type(value).__name__}" + + +def _compute_metric_for_pair( + reference_value: Any, + candidate_value: Any, +) -> tuple[int, str, str, float, float, float, float] | None: + ref_flat, ref_shape = _flatten_for_metric(reference_value) + cand_flat, cand_shape = _flatten_for_metric(candidate_value) + + if ref_flat is None or cand_flat is None: + return None + + compared = min(ref_flat.numel(), cand_flat.numel()) + if compared == 0: + return None + + ref = torch.nan_to_num(ref_flat[:compared], nan=0.0, posinf=0.0, neginf=0.0) + cand = torch.nan_to_num(cand_flat[:compared], nan=0.0, posinf=0.0, neginf=0.0) + diff = cand - ref + abs_diff = diff.abs() + + max_abs = float(abs_diff.max().item()) + mean_abs = float(abs_diff.mean().item()) + mse = float((diff * diff).mean().item()) + + ref_norm = float(ref.norm().item()) + cand_norm = float(cand.norm().item()) + if ref_norm == 0.0 or cand_norm == 0.0: + cosine = 1.0 if ref_norm == cand_norm else 0.0 + else: + cosine = float(torch.nn.functional.cosine_similarity(ref, cand, dim=0).item()) + if math.isnan(cosine): + cosine = 0.0 + + return compared, ref_shape, cand_shape, max_abs, mean_abs, mse, cosine + + +def _compute_layer_metrics( + matches: Iterable[MatchRecord], + reference_outputs: Mapping[DebugHandle, Any], + candidate_outputs: Mapping[DebugHandle, Any], +) -> list[LayerMetric]: + metrics: list[LayerMetric] = [] + for match in matches: + if match.reference_debug_handle not in reference_outputs: + continue + if match.candidate_debug_handle not in candidate_outputs: + continue + computed = _compute_metric_for_pair( + reference_outputs[match.reference_debug_handle], + candidate_outputs[match.candidate_debug_handle], + ) + if computed is None: + continue + ( + compared, + reference_shape, + candidate_shape, + max_abs, + mean_abs, + mse, + cosine, + ) = computed + # Severity of performance drop: larger is worse. + severity = max_abs + max(0.0, 1.0 - cosine) + metrics.append( + LayerMetric( + candidate_node=match.candidate_node, + reference_node=match.reference_node, + candidate_debug_handle=match.candidate_debug_handle, + reference_debug_handle=match.reference_debug_handle, + matched_by=match.matched_by, + numel_compared=compared, + candidate_shape=candidate_shape, + reference_shape=reference_shape, + max_abs_err=max_abs, + mean_abs_err=mean_abs, + mse=mse, + cosine_similarity=cosine, + severity_score=severity, + ) + ) + return metrics + + +def _add_accuracy_extension(exporter: FXGraphExporter, metrics: Iterable[LayerMetric]) -> None: + ext = GraphExtension(id="per_layer_accuracy", name="Per-layer Accuracy (Worst Sample)") + for metric in metrics: + ext.add_node_data( + metric.candidate_node, + { + "reference_node": metric.reference_node, + "candidate_debug_handle": list(metric.candidate_debug_handle), + "reference_debug_handle": list(metric.reference_debug_handle), + "matched_by": metric.matched_by, + "numel_compared": metric.numel_compared, + "candidate_shape": metric.candidate_shape, + "reference_shape": metric.reference_shape, + "max_abs_err": metric.max_abs_err, + "mean_abs_err": metric.mean_abs_err, + "mse": metric.mse, + "cosine_similarity": metric.cosine_similarity, + "severity_score": metric.severity_score, + }, + ) + + ext.set_label_formatter( + lambda d: [ + f"severity={d.get('severity_score', 0.0):.2e}", + f"max_abs={d.get('max_abs_err', 0.0):.2e}", + ] + ) + ext.set_tooltip_formatter( + lambda d: [ + f"match={d.get('matched_by', 'n/a')}", + f"ref_node={d.get('reference_node', 'n/a')}", + f"ref_debug_handle={d.get('reference_debug_handle', [])}", + f"cand_debug_handle={d.get('candidate_debug_handle', [])}", + f"shape(ref)={d.get('reference_shape', 'n/a')}", + f"shape(cand)={d.get('candidate_shape', 'n/a')}", + f"numel={d.get('numel_compared', 0)}", + f"severity={d.get('severity_score', 0.0):.6e}", + f"max_abs={d.get('max_abs_err', 0.0):.6e}", + f"mean_abs={d.get('mean_abs_err', 0.0):.6e}", + f"mse={d.get('mse', 0.0):.6e}", + f"cos={d.get('cosine_similarity', 0.0):.6f}", + ] + ) + # Red severity map: higher severity => stronger red. + ext.set_color_rule( + NumericColorRule(attribute="severity_score", cmap="reds", handle_outliers=True) + ) + exporter.add_extension(ext) + + +def _to_primary_tensor(value: Any) -> torch.Tensor | None: + if isinstance(value, torch.Tensor): + return value + if hasattr(value, "logits") and isinstance(value.logits, torch.Tensor): + return value.logits + if isinstance(value, (tuple, list)): + for item in value: + t = _to_primary_tensor(item) + if t is not None: + return t + if isinstance(value, dict): + for item in value.values(): + t = _to_primary_tensor(item) + if t is not None: + return t + return None + + +def _score_samples_by_e2e_drop( + reference_graph: torch.fx.GraphModule, + candidate_graph: torch.fx.GraphModule, + samples: Sequence[tuple[torch.Tensor, ...]], +) -> tuple[list[SampleScore], int]: + scores: list[SampleScore] = [] + with torch.no_grad(): + for idx, sample in enumerate(samples): + ref_out = reference_graph(*sample) + cand_out = candidate_graph(*sample) + ref_t = _to_primary_tensor(ref_out) + cand_t = _to_primary_tensor(cand_out) + if ref_t is None or cand_t is None: + scores.append( + SampleScore( + sample_index=idx, + mse=float("inf"), + max_abs_err=float("inf"), + cosine_similarity=0.0, + drop_score=float("inf"), + ) + ) + continue + + ref = ref_t.detach().cpu().to(torch.float64).reshape(-1) + cand = cand_t.detach().cpu().to(torch.float64).reshape(-1) + compared = min(ref.numel(), cand.numel()) + if compared == 0: + scores.append( + SampleScore( + sample_index=idx, + mse=float("inf"), + max_abs_err=float("inf"), + cosine_similarity=0.0, + drop_score=float("inf"), + ) + ) + continue + + ref = torch.nan_to_num(ref[:compared], nan=0.0, posinf=0.0, neginf=0.0) + cand = torch.nan_to_num(cand[:compared], nan=0.0, posinf=0.0, neginf=0.0) + diff = cand - ref + mse = float((diff * diff).mean().item()) + max_abs = float(diff.abs().max().item()) + + ref_norm = float(ref.norm().item()) + cand_norm = float(cand.norm().item()) + if ref_norm == 0.0 or cand_norm == 0.0: + cosine = 1.0 if ref_norm == cand_norm else 0.0 + else: + cosine = float(torch.nn.functional.cosine_similarity(ref, cand, dim=0).item()) + if math.isnan(cosine): + cosine = 0.0 + + # Composite score for selecting the worst E2E sample. + drop_score = max_abs + mse + 5.0 * max(0.0, 1.0 - cosine) + scores.append( + SampleScore( + sample_index=idx, + mse=mse, + max_abs_err=max_abs, + cosine_similarity=cosine, + drop_score=drop_score, + ) + ) + + worst = max(scores, key=lambda s: s.drop_score) + return scores, worst.sample_index + + +def _write_compare_html( + output_path: Path, + panels: Sequence[dict[str, Any]], + default_columns: int, +) -> None: + js_bundle = FXGraphExporter._load_viewer_js_bundle() + + panel_html = [] + for idx, panel in enumerate(panels): + panel_html.append( + f""" +
+
{panel['title']}
+
+
+""" + ) + + html = f""" + + + + FX Viewer Accuracy Compare + + + +
+
FX Graph Multi-Compare
+ + + +
+
{''.join(panel_html)} +
+ + + + + +""" + output_path.write_text(html) + + +def _write_metrics_json( + output_path: Path, + metrics: Sequence[LayerMetric], + match_stats: Mapping[str, int], + metadata: Mapping[str, Any], + sample_scores: Sequence[SampleScore], + worst_sample_index: int, +) -> None: + payload = { + "metadata": dict(metadata), + "match_stats": dict(match_stats), + "worst_sample_index": worst_sample_index, + "sample_scores": [asdict(s) for s in sample_scores], + "summary": { + "layers_with_metrics": len(metrics), + "severity_max": max((m.severity_score for m in metrics), default=0.0), + "severity_mean": ( + sum(m.severity_score for m in metrics) / len(metrics) if metrics else 0.0 + ), + "max_abs_err_max": max((m.max_abs_err for m in metrics), default=0.0), + "max_abs_err_mean": ( + sum(m.max_abs_err for m in metrics) / len(metrics) if metrics else 0.0 + ), + "cosine_similarity_mean": ( + sum(m.cosine_similarity for m in metrics) / len(metrics) if metrics else 0.0 + ), + }, + "layers": [asdict(metric) for metric in metrics], + "top10_severity": [ + asdict(metric) + for metric in sorted(metrics, key=lambda m: m.severity_score, reverse=True)[:10] + ], + } + output_path.write_text(json.dumps(payload, indent=2)) + + +def _build_graph_pair_fake_quant( + model: torch.nn.Module, + export_sample: tuple[torch.Tensor, ...], +) -> GraphPair: + reference_ep = _export_with_debug_handles(model, export_sample) + candidate_model = _make_fake_quantized_copy(model) + candidate_ep = _export_with_debug_handles(candidate_model, export_sample) + return GraphPair( + pipeline="fake_quant", + reference_name="Reference Float", + candidate_name="Candidate Fake-Quantized", + reference_graph=reference_ep.module(), + candidate_graph=candidate_ep.module(), + metadata={ + "method": "deepcopy + weight rounding to int8 grid", + "qnn_sdk_root": os.getenv("QNN_SDK_ROOT", ""), + }, + ) + + +def _build_graph_pair_qualcomm_ptq( + model: torch.nn.Module, + export_sample: tuple[torch.Tensor, ...], + calibration_samples: Sequence[tuple[torch.Tensor, ...]], + soc_model: str, + backend_name: str, +) -> GraphPair: + from executorch.backends.qualcomm.quantizer.quantizer import QuantDtype + from executorch.backends.qualcomm.serialization.qc_schema import ( + QnnExecuTorchBackendType, + ) + try: + from executorch.examples.qualcomm.utils import make_quantizer + except ModuleNotFoundError: + from examples.qualcomm.utils import make_quantizer + from torchao.quantization.pt2e.quantize_pt2e import convert_pt2e, prepare_pt2e + + if os.getenv("QNN_SDK_ROOT") is None: + raise RuntimeError( + "QNN_SDK_ROOT is not set. Run: source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh" + ) + + backend = getattr(QnnExecuTorchBackendType, f"k{backend_name.title()}Backend") + + reference_ep = _export_with_debug_handles(model, export_sample) + quant_input_graph = reference_ep.module() + reference_graph = copy.deepcopy(quant_input_graph) + + quantizer = make_quantizer( + quant_dtype=QuantDtype.use_8a8w, + backend=backend, + soc_model=soc_model, + ) + annotated_model = prepare_pt2e(quant_input_graph, quantizer) + + with torch.no_grad(): + for sample in calibration_samples: + annotated_model(*sample) + + candidate_graph = convert_pt2e(annotated_model) + _ensure_graph_module_debug_handles(candidate_graph) + + return GraphPair( + pipeline="qualcomm_ptq", + reference_name="Reference Float (Exported)", + candidate_name=f"Candidate Qualcomm PTQ ({soc_model}, {backend_name.upper()})", + reference_graph=reference_graph, + candidate_graph=candidate_graph, + metadata={ + "method": "QnnQuantizer + prepare_pt2e/convert_pt2e", + "soc_model": soc_model, + "backend": backend_name, + "calibration_samples": len(calibration_samples), + "qnn_sdk_root": os.getenv("QNN_SDK_ROOT", ""), + }, + ) + + +def _run_single_pipeline( + graph_pair: GraphPair, + samples: Sequence[tuple[torch.Tensor, ...]], + pipeline_output_dir: Path, + seed: int, + model_name: str, + default_compare_columns: int, +) -> None: + pipeline_output_dir.mkdir(parents=True, exist_ok=True) + + print(f"[{graph_pair.pipeline}] Scoring end-to-end drop over {len(samples)} samples...") + sample_scores, worst_sample_idx = _score_samples_by_e2e_drop( + graph_pair.reference_graph, + graph_pair.candidate_graph, + samples, + ) + worst_sample = samples[worst_sample_idx] + worst_score = next(s for s in sample_scores if s.sample_index == worst_sample_idx) + print( + f"[{graph_pair.pipeline}] Worst sample idx={worst_sample_idx}, " + f"drop={worst_score.drop_score:.6e}, max_abs={worst_score.max_abs_err:.6e}, " + f"mse={worst_score.mse:.6e}, cos={worst_score.cosine_similarity:.6f}" + ) + + print(f"[{graph_pair.pipeline}] Capturing per-layer outputs on worst sample...") + reference_outputs = _capture_outputs(graph_pair.reference_graph, worst_sample) + candidate_outputs = _capture_outputs(graph_pair.candidate_graph, worst_sample) + + print(f"[{graph_pair.pipeline}] Building debug-handle mappings...") + reference_map = get_aot_debug_handle_to_op_name_mapping(graph_pair.reference_graph) + candidate_map = get_aot_debug_handle_to_op_name_mapping(graph_pair.candidate_graph) + matches, match_stats = _match_nodes(reference_map, candidate_map) + + print(f"[{graph_pair.pipeline}] Computing per-layer metrics...") + metrics = _compute_layer_metrics(matches, reference_outputs, candidate_outputs) + + print(f"[{graph_pair.pipeline}] Exporting fx_viewer HTML...") + reference_exporter = FXGraphExporter(graph_pair.reference_graph) + candidate_exporter = FXGraphExporter(graph_pair.candidate_graph) + _add_accuracy_extension(candidate_exporter, metrics) + + reference_html = pipeline_output_dir / "reference_fx_graph.html" + candidate_html = pipeline_output_dir / "candidate_fx_graph_per_layer_accuracy.html" + compare_html = pipeline_output_dir / "compare_side_by_side.html" + metrics_json = pipeline_output_dir / "per_layer_accuracy_metrics.json" + + reference_payload = reference_exporter.generate_json_payload() + candidate_payload = candidate_exporter.generate_json_payload() + + reference_exporter.export_html(str(reference_html)) + candidate_exporter.export_html(str(candidate_html)) + + panels = [ + { + "title": graph_pair.reference_name, + "payload": reference_payload, + }, + { + "title": f"{graph_pair.candidate_name} [worst sample={worst_sample_idx}]", + "payload": candidate_payload, + }, + ] + _write_compare_html(compare_html, panels=panels, default_columns=default_compare_columns) + + _write_metrics_json( + metrics_json, + metrics, + match_stats=match_stats, + metadata={ + "pipeline": graph_pair.pipeline, + "model": model_name, + "seed": seed, + "reference_node_count": len(list(graph_pair.reference_graph.graph.nodes)), + "candidate_node_count": len(list(graph_pair.candidate_graph.graph.nodes)), + "reference_captured_outputs": len(reference_outputs), + "candidate_captured_outputs": len(candidate_outputs), + **graph_pair.metadata, + }, + sample_scores=sample_scores, + worst_sample_index=worst_sample_idx, + ) + + top5 = sorted(metrics, key=lambda m: m.severity_score, reverse=True)[:5] + print(f"[{graph_pair.pipeline}] Done. Output: {pipeline_output_dir}") + print(f"[{graph_pair.pipeline}] Top-5 severity layers (red = worse):") + for item in top5: + print( + " " + f"{item.candidate_node}: severity={item.severity_score:.6e}, " + f"max_abs={item.max_abs_err:.6e}, cos={item.cosine_similarity:.6f}, " + f"match={item.matched_by}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Standalone per-layer accuracy demo for fx_viewer.") + parser.add_argument("--model", choices=["toy", "swin"], default="swin") + parser.add_argument("--output-dir", default="fx_viewer_accuracy_demo") + parser.add_argument("--seed", type=int, default=1126) + parser.add_argument("--num-samples", type=int, default=10) + parser.add_argument( + "--pipeline", + choices=["fake_quant", "qualcomm_ptq", "both"], + default="both", + help="Which comparison pipeline(s) to run.", + ) + parser.add_argument("--soc-model", default="SM8650") + parser.add_argument("--backend", choices=["htp", "gpu"], default="htp") + parser.add_argument("--calibration-steps", type=int, default=4) + parser.add_argument("--compare-columns", type=int, default=2) + args = parser.parse_args() + + _set_seed(args.seed) + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + if args.model == "swin": + reference_model, input_shape = _build_swin_model() + else: + reference_model, input_shape = _build_toy_model() + + samples = _make_random_samples(input_shape, max(1, args.num_samples)) + export_sample = samples[0] + + requested_pipelines = ( + ["fake_quant", "qualcomm_ptq"] if args.pipeline == "both" else [args.pipeline] + ) + + for pipeline in requested_pipelines: + if pipeline == "fake_quant": + pair = _build_graph_pair_fake_quant(reference_model, export_sample) + elif pipeline == "qualcomm_ptq": + calib_samples = samples[: max(1, args.calibration_steps)] + pair = _build_graph_pair_qualcomm_ptq( + reference_model, + export_sample, + calibration_samples=calib_samples, + soc_model=args.soc_model, + backend_name=args.backend, + ) + else: + raise AssertionError(f"Unsupported pipeline: {pipeline}") + + _run_single_pipeline( + pair, + samples=samples, + pipeline_output_dir=output_dir / pipeline, + seed=args.seed, + model_name=args.model, + default_compare_columns=max(1, min(4, args.compare_columns)), + ) + + print("\nDemo complete.") + print(f"Output root: {output_dir}") + for pipeline in requested_pipelines: + print(f" - {pipeline}/reference_fx_graph.html") + print(f" - {pipeline}/candidate_fx_graph_per_layer_accuracy.html") + print(f" - {pipeline}/compare_side_by_side.html") + print(f" - {pipeline}/per_layer_accuracy_metrics.json") + + +if __name__ == "__main__": + main() From 1d67726ef0c7a101d53b22c425edd825a188c883 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 19:15:42 +0800 Subject: [PATCH 06/65] fx_viewer/docs: document RFC API surface and runtime architecture --- .../API_REFACTOR_IMPLEMENTATION_LOG.md | 168 ++++++++ .../utils/fx_viewer/JS_COMMAND_UX_TRACE.md | 103 +++++ backends/qualcomm/utils/fx_viewer/README.md | 333 +++------------ .../RFC_API_IMPLEMENTATION_STATUS.md | 60 +++ .../RFC_FX_VIEWER_API_INTERFACE.html | 350 ++++++++++++++++ .../fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md | 380 ++++++++++++++++++ .../utils/fx_viewer/templates/README.md | 140 ++----- 7 files changed, 1162 insertions(+), 372 deletions(-) create mode 100644 backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md create mode 100644 backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md create mode 100644 backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md create mode 100644 backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html create mode 100644 backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md diff --git a/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md b/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md new file mode 100644 index 00000000000..842ca5b61e2 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md @@ -0,0 +1,168 @@ +# FX Viewer API Refactor Implementation Log + +Date: 2026-03-13 +Owner: Codex + +## Step 0: Scope and guardrails + +Goal: +1. Implement the RFC v1-style JS API surface in current `fx_viewer` templates. +2. Validate with two example families: topological extension demo and per-layer accuracy demo. +3. Build a single HTML testcase harness that reuses one JS bundle and shared payloads. + +Constraints/decisions: +1. Keep payload schema (`base` + `extensions`) unchanged. +2. Allow a compatibility constructor path for now (`new FXGraphViewer(containerId, payload)`), while adding the new factory-style API. +3. Prioritize deterministic behavior and explicit precedence over broad feature scope. + +## Step 1: Architecture audit (completed) + +Files inspected: +1. `templates/fx_graph_viewer.js` +2. `templates/view_controller.js` +3. `templates/ui_manager.js` +4. `templates/graph_data_store.js` +5. `templates/minimap_renderer.js` +6. `exporter.py` +7. `examples/demo_fx_viewer_extensions.py` +8. `examples/demo_per_layer_accuracy_fx.py` + +Key findings: +1. UI synchronization gap exists: external state changes for theme/layer/colorBy are not fully reflected in controls. +2. Layout ownership is hardcoded to split shell; minimap/info mounting is coupled to sidebar. +3. Compare behavior in accuracy demo is achieved by demo-side monkey patching of `selectNode`. +4. Data-layer runtime mutation APIs do not exist (only static extension payload at init). + +Implementation plan selected: +1. Add normalized config model with precedence rules inside `FXGraphViewer`. +2. Add API methods categorized in RFC. +3. Add explicit UI reconciliation (`syncControlsFromState`) in `UIManager`. +4. Add runtime layer mutation helpers in `GraphDataStore`. +5. Add small compare orchestrator class (`FXGraphCompare`). + +## Step 2: v1 API implementation (completed) + +Implemented modules: +1. `templates/fx_graph_viewer.js` +2. `templates/view_controller.js` +3. `templates/ui_manager.js` +4. `templates/graph_data_store.js` +5. `templates/minimap_renderer.js` + +### 2.1 Decisions + +1. Keep constructor compatibility (`new FXGraphViewer(containerId, payload)`) while adding v1 factory (`FXGraphViewer.create(config)`). +2. Implement `preset` as baseline defaults (`split`, `compact`, `headless`, `custom`) merged with explicit `layout`. +3. Enforce slot precedence by resolving mount slots before layout shell usage. +4. Add explicit event system and categorized APIs now; defer strict schema validator to a follow-up. + +### 2.2 Implemented API surface + +1. Construction: + - `FXGraphViewer.create(config)` +2. State/events: + - `getState`, `setState`, `replaceState`, `batch` + - `on`, `off` with `statechange`, `selectionchange`, `themechange`, `layoutchange` +3. Appearance/control: + - `setTheme`, `setLayers`, `setColorBy`, `setUIVisibility`, `setLayout` +4. Navigation: + - `selectNode`, `clearSelection`, `search`, `zoomToFit`, `panToNode`, `animateToNode` +5. Runtime layer mutation: + - `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` +6. Layout/lifecycle: + - `enterFullscreen`, `exitFullscreen`, `destroy` +7. Compare: + - `FXGraphCompare.create({ viewers, layout, sync })` + - `setColumns`, `setCompact`, `setSync`, `destroy` + +### 2.3 UI synchronization fix + +Added `UIManager.syncControlsFromState()` and called it from controller state updates so external JS changes reflect in: +1. Theme select +2. Layer checkboxes +3. Color-by radios +4. Highlight toggle + +This resolves the previously observed drift issue. + +## Step 3: unified testcase harness (completed) + +Created: +1. `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` +2. Generated output: + - `backends/qualcomm/utils/fx_viewer/examples/fx_viewer_api_test_harness.html` + +Harness characteristics: +1. Single shared JS bundle from exporter. +2. Shared payload pool generated once per run. +3. Testcase selector with source panes (HTML input + JS input) and live outcome panel. +4. Runtime log pane for each testcase. + +Included testcases: +1. `topology_split` +2. `headless_slots` +3. `accuracy_dynamic` +4. `compare_sync` + +## Step 4: environment and import path resolution (completed) + +Observed issue: +1. `demo_per_layer_accuracy_fx.py` could import `fx_viewer` from `.venv/site-packages` when source tree path was not resolved correctly. +2. Site-packages copy lacked `templates/*.js`, causing `FileNotFoundError` during `export_html`. + +Fix applied: +1. Updated source-path bootstrap in demos to add repo parent (`~/`) semantics correctly for local source imports. +2. Validated with required run convention: + - source venv + - source QAIRT env + - `set -px PYTHONPATH ~/` + +## Step 5: validation results (completed) + +### 5.1 Static checks + +1. `node --check` passed for all modified JS template files. +2. `python3 -m py_compile` passed for modified Python scripts. + +### 5.2 Runtime checks + +1. `demo_per_layer_accuracy_fx.py` with `--pipeline both` succeeded (fake_quant + qualcomm_ptq): + - Output root: `/tmp/fx_viewer_acc_api_regress6` +2. `demo_fx_viewer_extensions.py --model swin` succeeded: + - Output: `/tmp/fx_viewer_ext_regress/swin_graph_v3_extensions.html` +3. Harness generation succeeded: + - `backends/qualcomm/utils/fx_viewer/examples/fx_viewer_api_test_harness.html` + +## Notes + +1. The warning from backend opinfo adapter is environment/version related and non-blocking for these demos. +2. Follow-up work recommended for strict runtime config validation and more granular compare sync modes. + +## Step 6: testcase bugfixes from review (completed) + +User-reported issues addressed: + +1. `headless_slots` testcase failed with `root mount not found: #case_view`. + - Cause: testcase HTML did not include `#case_view` while JS mounted to that id. + - Fix: wrapped headless grid HTML in `
...
`. + +2. Sidebar splitter between info/minimap appeared at top. + - Cause: `resizerH` was appended before info/minimap order was established. + - Fix: create `resizerH` first but insert it into DOM only after minimap mount, positioned before minimap container so it sits between info panel and minimap. + +3. Outcome panel requested resizable behavior to validate container resize handling. + - Fix: added resizable `#outcomeHost` (`resize: both`) in harness and kept viewer mount in nested `#sandbox`. + - Supporting runtime behavior: `CanvasRenderer` now observes container size via `ResizeObserver` and triggers `resize()`. + +## Step 7: harness UX refinements (completed) + +User-requested updates: +1. Full-screen default harness layout. +2. Run executes edited HTML/JS, not forced template reset. + +Changes in `generate_api_test_harness.py`: +1. Harness page grid changed to full viewport (`height: 100vh`). +2. Body split uses responsive width (`minmax(360px, 36vw) 1fr`). +3. Testcase container heights switched to `height: 100%` to consume outcome pane. +4. Added per-testcase draft storage (`caseDrafts`) so edits persist while switching cases. +5. `runCurrentCase()` now executes `htmlCode.value` and `jsCode.value` directly without calling `renderCaseMeta()`. diff --git a/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md b/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md new file mode 100644 index 00000000000..4b681f99835 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md @@ -0,0 +1,103 @@ +# JS Command -> UX Trace (Current Runtime) + +This document records how each public command affects runtime behavior and user experience. + +## FXGraphViewer Commands + +1. `create(config)` +- Code: `backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js` +- Effect: resolves mounts, applies preset/layout precedence, initializes renderers/UI. +- UX: viewer appears with split/headless/custom shell and requested controls. + +2. `getState()` +- Effect: returns snapshot (selection, camera, theme, layers, ui visibility, layout state). +- UX: host can inspect current viewer state and build synchronized custom controls. + +3. `setState(patch)` +- Effect: applies patch to controller state; camera/search handled explicitly. +- UX: host can programmatically drive viewer interactions and visual state. + +4. `replaceState(next)` +- Effect: resets canonical state fields and optional camera/search. +- UX: deterministic reset/restore experience for scripted debugging flows. + +5. `setTheme(name)` +- Effect: updates `themeName`, re-themes DOM + canvas/minimap. +- UX: immediate light/dark (or registered custom) switch with consistent UI tokens. + +6. `setLayers(layerIds)` +- Effect: changes active extensions and recomputes virtual graph. +- UX: overlay data appears/disappears without rebuilding page. + +7. `setColorBy(layerId)` +- Effect: color source switches (`base` or extension). +- UX: node colors + legend update to chosen metric/category. + +8. `selectNode(nodeId, opts)` +- Effect: selection state updates, info panel refreshes, optional animation/center. +- UX: focus jumps to selected node with contextual dependency highlighting. + +9. `clearSelection()` +- Effect: clears selection/ancestor/descendant sets. +- UX: graph returns to neutral exploration mode. + +10. `search(query)` +- Effect: updates search candidates and search UI. +- UX: fuzzy lookup and quick navigation through large graphs. + +11. `zoomToFit()` / `panToNode()` / `animateToNode()` +- Effect: updates camera transform. +- UX: smooth camera navigation and context framing. + +12. `setUIVisibility(flags)` +- Effect: hides/shows taskbar/search/layers/theme/legend/fullscreen control. +- UX: host can build focused views (for demos, report sections, compare mode). + +13. `setLayout(layoutPatch)` +- Effect: mutates layout behavior (sidebar visibility/width, minimap visibility/height, etc). +- UX: dynamic transitions between split/compact presentation modes. + +14. `upsertLayer/removeLayer/patchLayerNodes/setLayerLabel/setColorRule` +- Effect: mutates extension registry and refreshes active graph. +- UX: dynamic threshold sliders and runtime overlays work without export roundtrip. + +15. `enterFullscreen/exitFullscreen` +- Effect: browser fullscreen API on root container. +- UX: one-click deep-focus graph inspection; also wired to optional taskbar button. + +16. `on/off` +- Events: `statechange`, `selectionchange`, `themechange`, `layoutchange`, `error`. +- UX: host-side dashboards/custom widgets can stay synchronized. + +## FXGraphCompare Commands + +1. `FXGraphCompare.create({ viewers, layout, sync })` +- Effect: wires compare orchestration and optional container columns. +- UX: side-by-side analysis with centralized controls. + +2. `setColumns(n)` +- Effect: updates compare container grid columns. +- UX: quick N-pane layout changes for different screen sizes. + +3. `setCompact(bool)` +- Effect: toggles compact layout (sidebar/minimap/info visibility in viewers). +- UX: maximizes graph canvas area during compare. + +4. `setSync({ selection, camera, theme, layers })` +- Effect: controls cross-view propagation dimensions. +- UX: can lock only needed dimensions (e.g., selection only). + +## Key Internal Command Paths + +1. UI commands -> controller +- Files: `ui_manager.js`, `view_controller.js` +- UX: taskbar controls always use same state pipeline as external JS. + +2. Controller state -> store recompute +- File: `view_controller.js` -> `graph_data_store.js` +- UX: layer/color changes stay consistent in canvas, legend, minimap, info panel. + +3. Container resize -> canvas resize +- File: `canvas_renderer.js` +- Mechanism: `ResizeObserver` + window resize. +- UX: resizable host panes keep rendering sharp and correctly scaled. diff --git a/backends/qualcomm/utils/fx_viewer/README.md b/backends/qualcomm/utils/fx_viewer/README.md index 1a70faa9e9b..d1b7aebacf0 100644 --- a/backends/qualcomm/utils/fx_viewer/README.md +++ b/backends/qualcomm/utils/fx_viewer/README.md @@ -1,303 +1,100 @@ -## What This is fx_viewer +# fx_viewer -`fx_viewer` exports a PyTorch model graph and renders it as an interactive browser viewer. +`fx_viewer` exports FX graphs to interactive HTML and provides a state-driven JS runtime. -- Python side: - - traces/extracts FX graph - - computes layout (Grandalf/Sugiyama) - - builds payload (`base` + `extensions`) - - emits JSON/JS/HTML -- JavaScript side: - - renders graph canvas + minimap - - handles selection, search, zoom/pan - - toggles extension layers - - applies color-by mode - -## Why Yet Another Graph Visualizer? +## What It Provides -### Simplicity -- The whole visualization frontend is done within 2k lines of vallina Javascript (no library or dependency). -- No installation required, export standalone html that runs in any browser. +Python side: +1. Extract FX graph (`torch.export` / `torch.fx`). +2. Compute layout (Grandalf/Sugiyama). +3. Build payload (`base` + `extensions`). +4. Export JSON / JS snippet / standalone HTML. -### Integration & Customization -- Support Python extension to customize color display, insert additional data and labels, enable easily integration with executorch debuggers and profiling utilities. -- Simple JS API for embedding visualizer in custom HTML div, easily control or customize the interactive actions. +JS side: +1. Canvas graph + minimap + info panel + search. +2. Layer toggles and color-by. +3. State-driven API for embedding, compare, fullscreen, and runtime layer mutation. -### Performance -- Easily render 10k+ nodes, load the entire graph instantly. -- Much faster and smooth experience due to lightweight design and pre-computed graph layout. (compared to dagre based JS engines i.e. netron / model-explorer). +## RFC and Current Status +Primary RFC: +1. `backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md` - - -## Quick Start - -Use your executorch venv: - -```bash -source ~/executorch/.venv/bin/activate -``` - -Run extension demo (Swin + Llama): - -```bash -python examples/demo_fx_viewer_extensions.py --model both -``` - -This generates: -- `swin_graph_v3_extensions.html` -- `llama_graph_v3_extensions.html` +Implementation status: +1. `backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md` ## Python API -Core API: - ```python -from fx_viewer import FXGraphExporter, GraphExtension, CategoricalColorRule - -ep_model = torch.export.export(model, inputs, strict=False) -graph_module = ep_model.graph_module +from executorch.backends.qualcomm.utils.fx_viewer import ( + FXGraphExporter, + GraphExtension, + CategoricalColorRule, +) exporter = FXGraphExporter(graph_module) -ext = GraphExtension(id="backend_type", name="Backend Assignment") -ext.add_node_data("node_id1", {"backend": "cpu"}) -ext.add_node_data("node_id2", {"backend": "gpu"}) +ext = GraphExtension(id="backend", name="Backend Assignment") +ext.add_node_data("node_0", {"backend": "cpu"}) ext.set_color_rule(CategoricalColorRule(attribute="backend")) exporter.add_extension(ext) exporter.export_html("graph.html") ``` -Export options: -- `generate_json_payload()`: return payload dict in memory -- `export_json(path)`: write payload as JSON file -- `export_js(container_id)`: return embeddable JS snippet -- `export_html(path)`: write standalone HTML - -## Canonical Data Contract - -The exporter now uses typed wire-format dataclasses: - -- `GraphNode`: - - `id`, `label`, `x`, `y`, `width`, `height`, `info`, `tooltip`, `fill_color` -- `GraphEdge`: - - `v`, `w`, `points` - -Formatters should consume `GraphNode` only. Required processing fields like -`name/op/target/args/kwargs` are read from `node.info`. - -### JSON Schema Mapping - -The payload is emitted with `dataclasses.asdict(...)`, so these dataclasses are -the JSON schema source of truth. - -`GraphNode` -> `base.nodes[]` - -| Field | Type | Notes | -| --- | --- | --- | -| `id` | `str` | Node id (FX name) | -| `label` | `str` | Rendered title text | -| `x`, `y` | `float` | Layout position | -| `width`, `height` | `float` | Layout box size | -| `info` | `dict[str, Any]` | Core metadata used by search/info panel | -| `tooltip` | `list[str]` | Base tooltip lines | -| `fill_color` | `str \| None` | Optional node color | - -`GraphEdge` -> `base.edges[]` - -| Field | Type | Notes | -| --- | --- | --- | -| `v` | `str` | Source node id | -| `w` | `str` | Target node id | -| `points` | `list[{x: float, y: float}]` | Optional routed polyline | - -Top-level: -- `GraphPayload.base` -> `{legend, nodes, edges}` -- `GraphPayload.extensions` -> extension overlays keyed by extension id - -## Exporter Architecture (Phases) - -`FXGraphExporter.generate_json_payload()` is split into explicit phases: - -1. `trace_model()` -2. `extract_graph()` -3. `compute_layout()` -4. `build_base_payload()` -5. `build_extensions_payload()` - -This separation keeps behavior reviewable and testable. - -## JS Architecture - -The viewer is split into modules under `fx_viewer/templates/`: - -- `themes.js` -- `graph_data_store.js` -- `search_engine.js` -- `view_controller.js` -- `canvas_renderer.js` -- `minimap_renderer.js` -- `ui_manager.js` -- `fx_graph_viewer.js` - -Detailed JS API and load order are documented in: -- `fx_viewer/templates/README.md` - -## Information Flows - -Extension toggle flow: -1. UI checkbox/radio changes -2. `ViewerController.setState(...)` -3. `GraphDataStore.computeActiveGraph(...)` -4. minimap/legend/info refresh -5. full re-render - -Selection flow: -1. canvas click -2. controller computes ancestors/descendants -3. canvas + minimap highlight path -4. info panel shows merged metadata - -Search flow: -1. user types query -2. `SearchEngine.search(...)` -3. candidates shown in dropdown -4. hover/enter navigates or selects - -## Extension Authoring Guide - -`GraphExtension` adds optional node-level overlays on top of base graph structure. - -### Extension Working Logic (Key Contract) - -This is the most important extension contract: - -1. You populate extension data explicitly with `add_node_data(node_id, data)`. -2. `label_formatter(data)` and `tooltip_formatter(data)` receive exactly that stored `data` dict. -3. Formatters must only read keys that were explicitly written previously via `add_node_data(...)`. - -What formatters do **not** get automatically: -- full FX node object -- base graph node `info` -- global graph context - -Return contract: -- formatter output must be `list[str]` -- invalid output (or formatter exceptions) is ignored with warnings - -If you need base attributes (for example `target`, `op`) in extension label/tooltip, -copy them into extension `data` first, then read from formatter input. - -### Extension Skeleton - -```python -from fx_viewer import GraphExtension - -ext = GraphExtension(id="my_ext", name="My Extension") - -# Attach data to node ids from exported graph -ext.add_node_data("node_1", {"metric": 3.14, "tag": "hot"}) - -# Optional text inside node -ext.set_label_formatter(lambda data: [f"metric={data['metric']:.2f}"]) - -# Optional tooltip lines -ext.set_tooltip_formatter(lambda data: [f"tag={data['tag']}"]) -``` - -### Good vs Bad Formatter Usage - -```python -# GOOD: formatter reads only keys explicitly added before -ext.add_node_data(node_id, {"target": "aten.add", "latency_ms": 1.2}) -ext.set_label_formatter(lambda data: [f"target={data['target']}"]) -ext.set_tooltip_formatter(lambda data: [f"latency={data['latency_ms']}"]) - -# BAD: formatter assumes implicit fields that were never added -ext.set_label_formatter(lambda data: [f"shape={data['tensor_shape']}"]) # KeyError risk -``` - -Validation behavior: -- extension `id` and `name` must be non-empty -- extension IDs must be unique within one exporter -- formatters must return `list[str]` -- formatter failures emit warnings with extension/node context +Main exporter methods: +1. `generate_json_payload()` +2. `export_json(path)` +3. `export_js(container_id)` +4. `export_html(path)` -### Color Rules +## JS API (Runtime) -Color rules map extension data to `fill_color` and legend entries. +Core: +1. `FXGraphViewer.create(config)` +2. `getState`, `setState`, `replaceState`, `batch` +3. `on`, `off` -Available rules: -- `CategoricalColorRule(attribute, color_map=None)` -- `NumericColorRule(attribute, cmap="viridis", handle_outliers=True)` +Actions: +1. `setTheme`, `setLayers`, `setColorBy` +2. `selectNode`, `clearSelection`, `search` +3. `zoomToFit`, `panToNode`, `animateToNode` +4. `setUIVisibility`, `setLayout` +5. `enterFullscreen`, `exitFullscreen` -#### CategoricalColorRule +Runtime layer mutation: +1. `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` -Use for string-like buckets (`op type`, `device`, `stage`). +Compare: +1. `FXGraphCompare.create(...)` +2. `setColumns`, `setCompact`, `setSync`, `destroy` -```python -from fx_viewer import CategoricalColorRule +## Unified API Harness -ext.set_color_rule(CategoricalColorRule( - attribute="op", - color_map={"conv": "#ff9999", "linear": "#99ccff"} -)) -``` - -Behavior: -- if `color_map` has a key, use it -- otherwise, deterministic hash-based color is generated -- legend is stable across runs for same values +Generator: +1. `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` -Use when: -- categories are discrete -- relative ordering is not meaningful +Template + testcase catalog: +1. `backends/qualcomm/utils/fx_viewer/examples/harness_template.html` +2. `backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py` -#### NumericColorRule +Generated outputs: +1. `fx_viewer_api_test_harness_portable.html` +2. `fx_viewer_api_test_harness_qualcomm.html` -Use for continuous metrics (`latency`, `memory`, `topo_index`). +Testcase reference: +1. `backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md` -```python -from fx_viewer import NumericColorRule +## Recommended Run (bash) -ext.set_color_rule(NumericColorRule( - attribute="latency_ms", - cmap="viridis", # or Reds/Blues/Greens - handle_outliers=True -)) +```bash +source /home/boyucwsl/executorch/.venv/bin/activate +source /home/boyucwsl/executorch/qairt/2.37.0.250724/bin/envsetup.sh +export PYTHONPATH=~/:$PYTHONPATH +python backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py ``` -Behavior: -- normalizes values into `[min, max]` -- optional percentile clipping for outliers -- generates 5-step legend +## JS Internals -Use when: -- magnitude matters -- you want heatmap-like visual scanning - -### Practical Rule Selection - -- Prefer categorical when the value domain is small and semantic. -- Prefer numeric when values are measured quantities. -- For noisy metrics with extreme spikes, keep `handle_outliers=True`. -- For rank/index-like fields (`topological_order`), set `handle_outliers=False`. - -## Testing - -Contract tests live in: -- `tests/test_exporter_contract.py` - -They validate: -- default payload shape -- custom base label/tooltip formatter behavior -- extension merge behavior -- color rule legend stability - -Run: - -```bash -source ~/executorch/.venv/bin/activate -pytest -q tests/test_exporter_contract.py -``` +See: +1. `backends/qualcomm/utils/fx_viewer/templates/README.md` diff --git a/backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md b/backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md new file mode 100644 index 00000000000..28b8e56e8f1 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md @@ -0,0 +1,60 @@ +# RFC API Implementation Status + +Date: 2026-03-13 +Scope: `backends/qualcomm/utils/fx_viewer/templates/*` +Reference: `backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md` + +## Summary + +Most RFC APIs are implemented in the current JS runtime. +Remaining gaps are minor and documented below. + +## Implemented + +1. Construction and presets +- `FXGraphViewer.create(config)` +- Presets: `split`, `compact`, `headless`, `custom` +- Slot precedence and layout merge behavior + +2. Canonical state API +- `getState` +- `setState` +- `replaceState` (state replacement with camera/search handling) +- `batch` + +3. Convenience APIs +- `setTheme`, `setLayers`, `setColorBy` +- `selectNode`, `clearSelection`, `search`, `zoomToFit`, `panToNode`, `animateToNode` +- `setUIVisibility`, `setLayout` +- `enterFullscreen`, `exitFullscreen`, `destroy` + +4. Runtime layer mutation APIs +- `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` + +5. Events +- `statechange`, `selectionchange`, `themechange`, `layoutchange`, `error` +- `on`/`off` subscription model + +6. Compare API +- `FXGraphCompare.create` +- `setColumns` (applies to optional compare container) +- `setCompact`, `setSync`, `destroy` + +7. UI synchronization contract +- External state updates reflected in theme/layers/colorBy controls +- `syncControlsFromState()` in `UIManager` + +8. Fullscreen taskbar support +- Optional taskbar fullscreen button via `layout.fullscreen.button` / `ui.controls.fullscreenButton` + +## Partial / Follow-up + +1. Strict state schema validation +- RFC describes validation-rich state store; current implementation uses pragmatic checks and coercions. + +2. Theme registration depth +- `registerTheme` works; deeper token validation and compatibility checks are not yet strict. + +3. Compare camera/theme/layer sync +- Compare selection sync is implemented. +- Other sync dimensions are modeled in config but not fully propagated yet. diff --git a/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html b/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html new file mode 100644 index 00000000000..f3ab9b8fb0f --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html @@ -0,0 +1,350 @@ + + + + + + +RFC_FX_VIEWER_API_INTERFACE.html + + + + + + + +

RFC: fx_viewer API Interface v1 (Breaking)

+ +

Date: 2026-03-13
+Status: Draft for review
+Owners: Qualcomm Executorch debugging team
+Scope: backends/qualcomm/utils/fx_viewer public JavaScript API and embedding contract

+ +

1. Abstract

+ +

This RFC defines a breaking v1 API for fx_viewer so it can be used consistently in:

+ +
    +
  1. Standalone graph/accuracy demos.
  2. +
  3. Observatory GraphView integration.
  4. +
+ +

The v1 design is state-driven, layout-composable, compare-native, and explicit about ownership/override rules.

+ +

2. Background

+ +

2.1 What is fx_viewer

+ +

fx_viewer renders FX graph payloads (base graph + optional extension layers) with interactive controls (selection, search, theme, color-by, minimap, info panel).

+ +

2.2 What is Observatory (debugging_utils)

+ +

Observatory captures and organizes multiple debug records across compilation/runtime stages using a lens framework and report UI.

+ +

2.3 Why this RFC

+ +

Current integration works but requires ad-hoc JS/DOM coupling for layout, compare sync, and dynamic per-layer coloring. v1 makes these capabilities first-class.

+ +

3. Problem Statement

+ +
    +
  1. External API updates can desync built-in controls.
  2. +
  3. Layout shell and graph core are tightly coupled.
  4. +
  5. Host custom controls (sliders/jump links) lack stable contracts.
  6. +
  7. Compare orchestration is duplicated per demo.
  8. +
+ +

4. Goals

+ +
    +
  1. Single source of truth state.
  2. +
  3. Unified layout config with strict precedence rules.
  4. +
  5. Explicit host/viewer ownership rules.
  6. +
  7. Clear JS API categories.
  8. +
  9. First-class runtime data/layer mutation.
  10. +
  11. First-class N-view compare + sync.
  12. +
+ +

5. Non-Goals

+ +
    +
  1. Backward compatibility with old APIs.
  2. +
  3. Observatory backend schema details.
  4. +
  5. Large-graph performance redesign.
  6. +
+ +

6. Unified Config Model

+ +

FXGraphViewer.create(...) accepts one normalized config shape.

+ +

ts +const viewer = FXGraphViewer.create({ + payload, + mount: { + root: "#graph-root", + slots: { + canvas: "#canvas-slot", + toolbar: "#toolbar-slot", + info: "#info-slot", + minimap: "#minimap-slot", + legend: "#legend-slot", + }, + }, + layout: { + preset: "split", // split | compact | headless | custom + panels: { + sidebar: { visible: true, width: 420, resizable: true, collapsible: true }, + info: { visible: true, dock: "sidebar" }, + minimap: { visible: true, dock: "sidebar", height: 240, resizable: true }, + legend: { visible: true, dock: "toolbar" }, + }, + fullscreen: { enabled: true, button: true }, + }, + ui: { + controls: { + toolbar: true, + search: true, + layers: true, + colorBy: true, + theme: true, + minimapToggle: true, + zoomButtons: true, + clearSelection: true, + }, + }, + state: { + theme: "light", + activeExtensions: ["per_layer_accuracy"], + colorBy: "per_layer_accuracy", + selectedNodeId: null, + searchQuery: "", + highlightAncestors: true, + }, +}); +

+ +

7. Precedence and Ownership Rules

+ +

7.1 Precedence (highest to lowest)

+ +
    +
  1. Explicit mount.slots.* (placement owner).
  2. +
  3. Explicit layout.* fields (behavior/visibility/dock defaults).
  4. +
  5. layout.preset defaults.
  6. +
  7. Internal built-in defaults.
  8. +
+ +

Interpretation:

+ +
    +
  1. If mount.slots.info is given, info panel mounts there even if preset is split.
  2. +
  3. Preset cannot override explicit slot placement.
  4. +
  5. Explicit layout fields override preset behavior values.
  6. +
+ +

7.2 Ownership

+ +
    +
  1. Host owns slot nodes (#info-slot, #minimap-slot, etc).
  2. +
  3. Viewer never overwrites host attributes/classes/styles on slot nodes.
  4. +
  5. Viewer creates and owns child nodes mounted inside slot nodes.
  6. +
  7. If slot is absent, viewer creates and owns container nodes in preset/custom layout shell.
  8. +
+ +

7.3 HTML attribute behavior

+ +
    +
  1. Host HTML attributes are preserved.
  2. +
  3. Viewer style/classes apply to viewer-owned descendants.
  4. +
  5. Host can style slot containers; viewer guarantees stable mount points.
  6. +
+ +

8. Presets

+ +

preset is a layout baseline recipe only.

+ +
    +
  1. split: canvas + sidebar defaults.
  2. +
  3. compact: minimal chrome, graph-first.
  4. +
  5. headless: no shell; host provides slots.
  6. +
  7. custom: no defaults; all structure explicit.
  8. +
+ +

Rule: preset fills missing layout values only.

+ +

9. Public JS API (Categorized)

+ +

9.1 State APIs

+ +

ts +viewer.getState(): ViewerState; +viewer.setState(patch: Partial<ViewerState>, opts?: { source?: "api" | "ui" | "system" }): void; +viewer.replaceState(next: ViewerState, opts?: { source?: "api" | "ui" | "system" }): void; +viewer.batch(fn: () => void): void; // coalesce redraw/events +

+ +

9.2 Data/Layer Mutation APIs (runtime)

+ +

ts +viewer.upsertLayer(layerId: string, layerPayload: LayerPayload): void; +viewer.removeLayer(layerId: string): void; +viewer.patchLayerNodes(layerId: string, patchByNodeId: Record<string, NodePatch>): void; +viewer.setColorRule(layerId: string, colorRule: ColorRule): void; +viewer.setLayerLabel(layerId: string, label: string): void; +

+ +

Use these for slider-driven threshold coloring or dynamic labels. Do not mutate transient renderer-only state.

+ +

9.3 Selection/Camera APIs

+ +

ts +viewer.selectNode(nodeId: string, opts?: { animate?: boolean; center?: boolean; durationMs?: number }): void; +viewer.clearSelection(): void; +viewer.panToNode(nodeId: string): void; +viewer.animateToNode(nodeId: string, opts?: { durationMs?: number; easing?: string; k?: number }): void; +viewer.zoomToFit(): void; +

+ +

9.4 Appearance/UI APIs

+ +

ts +viewer.setTheme(themeName: string): void; +viewer.setLayers(layerIds: string[]): void; +viewer.setColorBy(layerId: string): void; +viewer.setUIVisibility(flags: Partial<UIVisibility>): void; +

+ +

9.5 Layout APIs

+ +

ts +viewer.setLayout(layoutPatch: Partial<LayoutConfig>): void; +viewer.enterFullscreen(): void; +viewer.exitFullscreen(): void; +

+ +

9.6 Lifecycle and Events

+ +

ts +viewer.on("statechange", (e) => {}); +viewer.on("selectionchange", (e) => {}); +viewer.on("layoutchange", (e) => {}); +viewer.on("themechange", (e) => {}); +viewer.on("error", (e) => {}); +viewer.destroy(): void; +

+ +

All events include source, timestamp, and relevant previous/next snapshots.

+ +

10. Runtime Mutation Semantics (Dynamic threshold use case)

+ +

Goal: support real-time slider callbacks that update node color/severity by node id.

+ +

Rules:

+ +
    +
  1. Mutate layer registry (upsertLayer, patchLayerNodes, setColorRule), not transient active node cache.
  2. +
  3. Layer toggle off/on must preserve patched values.
  4. +
  5. setColorBy(layerId) should immediately use latest patched values.
  6. +
  7. Prefer batch(...) for smooth updates.
  8. +
+ +

Example:

+ +

ts +slider.oninput = (threshold) => { + const patch = computePatch(threshold); // nodeId -> { value, color, label } + viewer.batch(() => { + viewer.patchLayerNodes("per_layer_accuracy", patch); + viewer.setColorBy("per_layer_accuracy"); + }); +}; +

+ +

11. UI Synchronization Contract

+ +

Built-in UI is a pure state adapter.

+ +
    +
  1. API updates must always reflect in UI controls.
  2. +
  3. UI interaction must always dispatch state mutations, never direct renderer mutations.
  4. +
  5. No hidden UI-only state for theme/layers/colorBy/search.
  6. +
+ +

This closes the current drift issue.

+ +

12. Compare API

+ +

```ts +const compare = FXGraphCompare.create({ + viewers: [viewerA, viewerB, viewerC], + layout: { columns: 2, compact: true }, + sync: { selection: true, camera: false, theme: false, layers: false }, +});

+ +

compare.setColumns(3); +compare.setSync({ selection: true, camera: true }); +compare.destroy(); +```

+ +

Semantics:

+ +
    +
  1. Source-guarded propagation avoids loops.
  2. +
  3. Selection sync applies only when target viewer has the node id.
  4. +
  5. Viewers remain independently usable outside compare orchestration.
  6. +
+ +

13. Breaking Changes

+ +

Removed in v1:

+ +
    +
  1. Legacy ad-hoc constructor mutation paths.
  2. +
  3. Direct DOM poking as integration contract.
  4. +
  5. Non-state-backed control update paths.
  6. +
+ +

14. Implementation Plan

+ +
    +
  1. Add ViewerStateStore (schema + reducer/actions + event bus).
  2. +
  3. Add LayoutManager (preset resolution + ownership + docking + splitters).
  4. +
  5. Refactor UIManager into state subscriber/dispatcher.
  6. +
  7. Add LayerRegistry mutation APIs.
  8. +
  9. Add FXGraphCompare orchestrator.
  10. +
  11. Migrate templates to v1-only paths.
  12. +
+ +

15. Testing Plan

+ +
    +
  1. Precedence tests: slot/layout/preset conflict resolution.
  2. +
  3. Ownership tests: host attributes preserved; viewer children mounted correctly.
  4. +
  5. UI sync tests: API-driven theme/layer/colorBy reflected in controls.
  6. +
  7. Runtime mutation tests: slider-style patch updates persist across layer toggles.
  8. +
  9. Compare tests: sync propagation and loop prevention.
  10. +
  11. E2E demo tests: standalone accuracy view + split/compact/headless embeddings.
  12. +
+ +

16. Risks and Mitigations

+ +
    +
  1. Risk: complexity increase from flexibility. +Mitigation: strict normalized config + validation errors.
  2. +
  3. Risk: regressions during migration. +Mitigation: interaction snapshot coverage for default exports.
  4. +
  5. Risk: unclear host/viewer responsibilities. +Mitigation: explicit ownership rules and precedence docs (Sections 7 and 6).
  6. +
+ +

17. Expected Outcome

+ +
    +
  1. Lens and demo authors get one clear API model.
  2. +
  3. Dynamic per-layer coloring/labels from JS becomes official and stable.
  4. +
  5. Layout composition is powerful without DOM hacks.
  6. +
  7. Compare/sync capabilities are reusable instead of reimplemented.
  8. +
+ + + diff --git a/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md b/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md new file mode 100644 index 00000000000..16f912d18e1 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md @@ -0,0 +1,380 @@ +# RFC: fx_viewer API Interface v1 (Breaking) + +Date: 2026-03-13 +Status: Draft for review +Owners: Qualcomm Executorch debugging team +Scope: `backends/qualcomm/utils/fx_viewer` public JavaScript API and embedding contract + +## 1. Abstract + +This RFC defines a breaking v1 API for `fx_viewer` so it can be used consistently in: + +1. Standalone graph/accuracy demos. +2. Observatory GraphView integration. + +The v1 design is state-driven, layout-composable, compare-native, and explicit about ownership/override rules. + +## 2. Background + +### 2.1 What is `fx_viewer` + +`fx_viewer` renders FX graph payloads (`base` graph + optional extension layers) with interactive controls (selection, search, theme, color-by, minimap, info panel). + +### 2.2 What is Observatory (`debugging_utils`) + +Observatory captures and organizes multiple debug records across compilation/runtime stages using a lens framework and report UI. + +### 2.3 Why this RFC + +Current integration works but requires ad-hoc JS/DOM coupling for layout, compare sync, and dynamic per-layer coloring. v1 makes these capabilities first-class. + +## 3. Problem Statement + +1. External API updates can desync built-in controls. +2. Layout shell and graph core are tightly coupled. +3. Host custom controls (sliders/jump links) lack stable contracts. +4. Compare orchestration is duplicated per demo. + +## 4. Goals + +1. Single source of truth state. +2. Unified layout config with strict precedence rules. +3. Explicit host/viewer ownership rules. +4. Clear JS API categories. +5. First-class runtime data/layer mutation. +6. First-class N-view compare + sync. + +## 5. Non-Goals + +1. Backward compatibility with old APIs. +2. Observatory backend schema details. +3. Large-graph performance redesign. + +## 6. Unified Config Model + +`FXGraphViewer.create(...)` accepts one normalized config shape. + +```ts +const viewer = FXGraphViewer.create({ + payload, + mount: { + root: "#graph-root", + slots: { + canvas: "#canvas-slot", + toolbar: "#toolbar-slot", + info: "#info-slot", + minimap: "#minimap-slot", + legend: "#legend-slot", + }, + }, + layout: { + preset: "split", // split | compact | headless | custom + panels: { + sidebar: { visible: true, width: 420, resizable: true, collapsible: true }, + info: { visible: true, dock: "sidebar" }, + minimap: { visible: true, dock: "sidebar", height: 240, resizable: true }, + legend: { visible: true, dock: "toolbar" }, + }, + fullscreen: { enabled: true, button: true }, + }, + ui: { + controls: { + toolbar: true, + search: true, + layers: true, + colorBy: true, + theme: true, + minimapToggle: true, + zoomButtons: true, + clearSelection: true, + }, + }, + state: { + theme: "light", + activeExtensions: ["per_layer_accuracy"], + colorBy: "per_layer_accuracy", + selectedNodeId: null, + searchQuery: "", + highlightAncestors: true, + }, +}); +``` + +## 7. Precedence and Ownership Rules + +## 7.1 Precedence (highest to lowest) + +1. Explicit `mount.slots.*` (placement owner). +2. Explicit `layout.*` fields (behavior/visibility/dock defaults). +3. `layout.preset` defaults. +4. Internal built-in defaults. + +Interpretation: + +1. If `mount.slots.info` is given, info panel mounts there even if preset is `split`. +2. Preset cannot override explicit slot placement. +3. Explicit layout fields override preset behavior values. + +## 7.2 Ownership + +1. Host owns slot nodes (`#info-slot`, `#minimap-slot`, etc). +2. Viewer never overwrites host attributes/classes/styles on slot nodes. +3. Viewer creates and owns child nodes mounted inside slot nodes. +4. If slot is absent, viewer creates and owns container nodes in preset/custom layout shell. + +## 7.3 HTML attribute behavior + +1. Host HTML attributes are preserved. +2. Viewer style/classes apply to viewer-owned descendants. +3. Host can style slot containers; viewer guarantees stable mount points. + +## 8. Presets + +`preset` is a layout baseline recipe only. + +1. `split`: canvas + sidebar defaults. +2. `compact`: minimal chrome, graph-first. +3. `headless`: no shell; host provides slots. +4. `custom`: no defaults; all structure explicit. + +Rule: preset fills missing layout values only. + +## 9. Public JS API (Categorized) + +## 9.1 State APIs + +```ts +viewer.getState(): ViewerState; +viewer.setState(patch: Partial, opts?: { source?: "api" | "ui" | "system" }): void; +viewer.replaceState(next: ViewerState, opts?: { source?: "api" | "ui" | "system" }): void; +viewer.batch(fn: () => void): void; // coalesce redraw/events +``` + +## 9.2 Data/Layer Mutation APIs (runtime) + +```ts +viewer.upsertLayer(layerId: string, layerPayload: LayerPayload): void; +viewer.removeLayer(layerId: string): void; +viewer.patchLayerNodes(layerId: string, patchByNodeId: Record): void; +viewer.setColorRule(layerId: string, colorRule: ColorRule): void; +viewer.setLayerLabel(layerId: string, label: string): void; +``` + +Use these for slider-driven threshold coloring or dynamic labels. Do not mutate transient renderer-only state. + +## 9.3 Selection/Camera APIs + +```ts +viewer.selectNode(nodeId: string, opts?: { animate?: boolean; center?: boolean; durationMs?: number }): void; +viewer.clearSelection(): void; +viewer.panToNode(nodeId: string): void; +viewer.animateToNode(nodeId: string, opts?: { durationMs?: number; easing?: string; k?: number }): void; +viewer.zoomToFit(): void; +``` + +## 9.4 Appearance/UI APIs + +```ts +viewer.setTheme(themeName: string): void; +viewer.setLayers(layerIds: string[]): void; +viewer.setColorBy(layerId: string): void; +viewer.setUIVisibility(flags: Partial): void; +``` + +## 9.5 Layout APIs + +```ts +viewer.setLayout(layoutPatch: Partial): void; +viewer.enterFullscreen(): void; +viewer.exitFullscreen(): void; +``` + +## 9.6 Lifecycle and Events + +```ts +viewer.on("statechange", (e) => {}); +viewer.on("selectionchange", (e) => {}); +viewer.on("layoutchange", (e) => {}); +viewer.on("themechange", (e) => {}); +viewer.on("error", (e) => {}); +viewer.destroy(): void; +``` + +All events include `source`, `timestamp`, and relevant previous/next snapshots. + +## 10. Runtime Mutation Semantics (Dynamic threshold use case) + +Goal: support real-time slider callbacks that update node color/severity by node id. + +Rules: + +1. Mutate layer registry (`upsertLayer`, `patchLayerNodes`, `setColorRule`), not transient active node cache. +2. Layer toggle off/on must preserve patched values. +3. `setColorBy(layerId)` should immediately use latest patched values. +4. Prefer `batch(...)` for smooth updates. + +Example: + +```ts +slider.oninput = (threshold) => { + const patch = computePatch(threshold); // nodeId -> { value, color, label } + viewer.batch(() => { + viewer.patchLayerNodes("per_layer_accuracy", patch); + viewer.setColorBy("per_layer_accuracy"); + }); +}; +``` + +## 11. UI Synchronization Contract + +Built-in UI is a pure state adapter. + +1. API updates must always reflect in UI controls. +2. UI interaction must always dispatch state mutations, never direct renderer mutations. +3. No hidden UI-only state for theme/layers/colorBy/search. + +This closes the current drift issue. + +## 12. Compare API + +```ts +const compare = FXGraphCompare.create({ + viewers: [viewerA, viewerB, viewerC], + layout: { columns: 2, compact: true }, + sync: { selection: true, camera: false, theme: false, layers: false }, +}); + +compare.setColumns(3); +compare.setSync({ selection: true, camera: true }); +compare.destroy(); +``` + +Semantics: + +1. Source-guarded propagation avoids loops. +2. Selection sync applies only when target viewer has the node id. +3. Viewers remain independently usable outside compare orchestration. + +## 13. Breaking Changes + +Removed in v1: + +1. Legacy ad-hoc constructor mutation paths. +2. Direct DOM poking as integration contract. +3. Non-state-backed control update paths. + +## 14. Implementation Plan + +1. Add `ViewerStateStore` (schema + reducer/actions + event bus). +2. Add `LayoutManager` (preset resolution + ownership + docking + splitters). +3. Refactor `UIManager` into state subscriber/dispatcher. +4. Add `LayerRegistry` mutation APIs. +5. Add `FXGraphCompare` orchestrator. +6. Migrate templates to v1-only paths. + +## 15. Testing Plan + +1. Precedence tests: slot/layout/preset conflict resolution. +2. Ownership tests: host attributes preserved; viewer children mounted correctly. +3. UI sync tests: API-driven theme/layer/colorBy reflected in controls. +4. Runtime mutation tests: slider-style patch updates persist across layer toggles. +5. Compare tests: sync propagation and loop prevention. +6. E2E demo tests: standalone accuracy view + split/compact/headless embeddings. + +## 16. Risks and Mitigations + +1. Risk: complexity increase from flexibility. + Mitigation: strict normalized config + validation errors. +2. Risk: regressions during migration. + Mitigation: interaction snapshot coverage for default exports. +3. Risk: unclear host/viewer responsibilities. + Mitigation: explicit ownership rules and precedence docs (Sections 7 and 6). + +## 17. Expected Outcome + +1. Lens and demo authors get one clear API model. +2. Dynamic per-layer coloring/labels from JS becomes official and stable. +3. Layout composition is powerful without DOM hacks. +4. Compare/sync capabilities are reusable instead of reimplemented. + +## Appendix A: Integration Recipes + +### A.1 Standalone Split Viewer + +```ts +const viewer = FXGraphViewer.create({ + payload, + mount: { root: "#graph-root" }, + layout: { preset: "split" }, + state: { theme: "light" }, +}); +viewer.init(); +``` + +### A.2 Headless Embedding with External Slots + +```ts +const viewer = FXGraphViewer.create({ + payload, + mount: { + root: "#root", + slots: { + canvas: "#canvas-slot", + info: "#info-slot", + minimap: "#minimap-slot", + legend: "#legend-slot", + }, + }, + layout: { + preset: "headless", + panels: { + minimap: { visible: true, height: 220 }, + info: { visible: true }, + legend: { visible: true }, + }, + }, + ui: { controls: { toolbar: false, search: false, layers: false, colorBy: false, theme: false } }, +}); +viewer.init(); +``` + +### A.3 Compare with Sync Toggle + +```ts +const compare = FXGraphCompare.create({ + viewers: [leftViewer, rightViewer], + layout: { columns: 2, compact: true }, + sync: { selection: true }, +}); + +syncCheckbox.onchange = (e) => compare.setSync({ selection: e.target.checked }); +compactCheckbox.onchange = (e) => compare.setCompact(e.target.checked); +``` + +## Appendix B: Runtime Threshold Coloring Recipe + +```ts +slider.oninput = (e) => { + const threshold = Number(e.target.value); + const patch = {}; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const sev = Number(nodeData.info.severity_score || 0); + patch[nodeId] = { + fill_color: sev >= threshold ? "#991b1b" : "#fecaca", + label_append: [`sev=${sev.toExponential(2)}`], + }; + }); + + viewer.batch(() => { + viewer.patchLayerNodes("per_layer_accuracy", patch); + viewer.setColorBy("per_layer_accuracy"); + }); +}; +``` + +## Appendix C: Precedence Quick Reference + +1. `mount.slots.*` decides where modules mount. +2. `layout.*` decides behavior for mounted modules. +3. `layout.preset` fills only missing layout values. +4. Internal defaults apply only when nothing else is specified. diff --git a/backends/qualcomm/utils/fx_viewer/templates/README.md b/backends/qualcomm/utils/fx_viewer/templates/README.md index fe2d75fa75c..dbabbf79f8e 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/README.md +++ b/backends/qualcomm/utils/fx_viewer/templates/README.md @@ -1,59 +1,54 @@ -# PyTorch FX Graph Viewer JS (Split Modules) +# fx_viewer JS Runtime (RFC v1) -## Description -This folder contains the split JavaScript runtime for the FX viewer used by `fx_viewer/exporter.py`. +This folder contains the browser runtime used by `FXGraphExporter`. -Current JS implementation provides a **standalone, embeddable, and highly interactive HTML5 Canvas viewer** for visualizing large-scale **PyTorch FX computational graphs**. -It is designed with a **modular architecture** to handle **thousands of nodes and edges** smoothly, without external dependencies. +## Runtime Model -The viewer renders a graph payload with: -- main canvas graph renderer -- minimap -- search -- info panel -- extension layer toggles/color-by +1. `FXGraphViewer` is the facade and public API. +2. `ViewerController` is the interaction/state controller. +3. `GraphDataStore` owns base + extension data and composes active virtual nodes. +4. Renderers (`CanvasRenderer`, `MinimapRenderer`) paint from controller/store state. +5. `UIManager` is a state adapter for taskbar/search/layers/info/legend controls. +6. `FXGraphCompare` orchestrates multi-view compare and synchronization. -## User Interactions & UX Features +## Public API (Implemented) -- **Interactive Canvas** - Users can drag to pan and use the mouse wheel to zoom. +Construction: +1. `FXGraphViewer.create(config)` -- **Smart Minimap** - A collapsible right-sidebar minimap shows a bird’s-eye view of the entire graph. - Users can drag a viewport box within the minimap to pan. +State/events: +1. `getState`, `setState`, `replaceState`, `batch` +2. `on`, `off` -- **Selection Mode** - Clicking a node or edge isolates its execution flow. - Immediate inputs/outputs are highlighted while unrelated branches are dimmed. +Viewer actions: +1. `setTheme`, `setLayers`, `setColorBy` +2. `selectNode`, `clearSelection`, `search` +3. `zoomToFit`, `panToNode`, `animateToNode` +4. `setUIVisibility`, `setLayout` +5. `enterFullscreen`, `exitFullscreen`, `destroy` -- **Info Panel** - Selecting or hovering over an element reveals PyTorch metadata - (tensor shape, dtype, target) in a scrollable panel with clickable links to jump to connected nodes. +Layer mutation: +1. `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` -- **Fuzzy Search** - A robust search bar allows querying nodes by ID, op type, or meta attributes, - featuring keyboard navigation and instant camera teleportation. +Compare: +1. `FXGraphCompare.create({ viewers, layout, sync })` +2. `setColumns`, `setCompact`, `setSync`, `destroy` -- **Theme Engine** - Seamless toggling between optimized **Light** and **Dark** mode palettes. +## Config Precedence -- **Extensibility** - Supports custom overlays via the Python `GraphExtension` API. - Users can toggle data layers on/off and switch coloring modes via the Layers Menu. +1. `mount.slots.*` (placement) has highest precedence. +2. Explicit `layout.*` overrides preset defaults. +3. `layout.preset` fills missing values. +4. Built-in defaults are last fallback. +## Key UX Behaviors -## Files and Responsibilities -- `themes.js`: shared theme tokens (`THEMES`). -- `graph_data_store.js`: payload normalization, adjacency, active virtual-node composition. -- `search_engine.js`: fuzzy search over active nodes. -- `view_controller.js`: state machine and interaction orchestration. -- `canvas_renderer.js`: primary graph rendering + mouse interactions. -- `minimap_renderer.js`: minimap rendering + navigation. -- `ui_manager.js`: taskbar/search/layers/info panel/legend DOM. -- `fx_graph_viewer.js`: top-level facade (`FXGraphViewer`) and layout shell. +1. API-driven changes reflect in UI controls (`theme/layers/colorBy` sync). +2. Host container resize triggers canvas resize (`ResizeObserver`). +3. Headless slots support custom HTML controls around GraphView. +4. Optional taskbar fullscreen button is enabled via `layout.fullscreen.button`. ## Script Load Order -Load in dependency order: 1. `themes.js` 2. `graph_data_store.js` @@ -63,66 +58,3 @@ Load in dependency order: 6. `minimap_renderer.js` 7. `ui_manager.js` 8. `fx_graph_viewer.js` - -## Payload Contract -Expected input to `new FXGraphViewer(containerId, payload)`: - -```js -{ - base: { - legend: [{ label, color }], - nodes: [{ id, label, x, y, width, height, info, tooltip, fill_color? }], - edges: [{ v, w, points? }] - }, - extensions: { - [extId]: { - name: string, - legend: [{ label, color }], - nodes: { - [nodeId]: { - info?: object, - tooltip?: string[], - label_append?: string[], - fill_color?: string - } - } - } - } -} -``` - -## Public JS API -Primary API is on `FXGraphViewer`: - -- `new FXGraphViewer(containerId, payload)`: construct viewer in target container. -- `viewer.init()`: initialize thumbnail + first fit/position. -- `viewer.renderAll()`: force redraw canvas and minimap. -- `viewer.selectNode(nodeId)`: select + center on node. -- `viewer.search(query)`: run search and populate search UI. - -## Embedding Example (Split JS) -```html -
- - - - - - - - - - -``` - -## Maintenance Notes -- Keep module boundaries strict: avoid cross-calling renderers from each other; route through `ViewerController`/`FXGraphViewer`. -- Prefer payload compatibility over UI-only changes; Python exporter and JS contract must stay aligned. -- If adding a new feature, document: - - state shape changes (`ViewerController.state`) - - payload schema changes (`GraphDataStore`) - - UX wiring (`UIManager` events) From 0337eed12e84327873d16bbbdba0b4594585ed2c Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 18:36:00 +0800 Subject: [PATCH 07/65] fx_viewer: harden viewer lifecycle and compare sync behavior --- .../fx_viewer/templates/canvas_renderer.js | 41 +++++-- .../fx_viewer/templates/fx_graph_viewer.js | 115 ++++++++++++------ .../fx_viewer/templates/minimap_renderer.js | 42 +++++-- .../utils/fx_viewer/templates/ui_manager.js | 23 +++- 4 files changed, 163 insertions(+), 58 deletions(-) diff --git a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js index 60b93704560..833ffbd1a86 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js @@ -90,6 +90,7 @@ class CanvasRenderer { constructor(container, viewer) { this.container = container; this.viewer = viewer; + this._teardownFns = []; this.canvasContainer = document.createElement('div'); this.canvasContainer.style.width = '100%'; @@ -106,7 +107,9 @@ class CanvasRenderer { this.lastMousePos = { x: 0, y: 0 }; this.resize(); - window.addEventListener('resize', () => this.resize()); + this._onWindowResize = () => this.resize(); + window.addEventListener('resize', this._onWindowResize); + this._teardownFns.push(() => window.removeEventListener('resize', this._onWindowResize)); this._resizeObserver = null; if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver(() => this.resize()); @@ -129,16 +132,24 @@ class CanvasRenderer { this._resizeObserver.disconnect(); this._resizeObserver = null; } + while (this._teardownFns.length > 0) { + const off = this._teardownFns.pop(); + try { + off(); + } catch (_) {} + } } setupEvents() { - this.canvas.addEventListener('mousedown', (e) => { + const onMouseDown = (e) => { this.isDragging = true; this.dragMoved = false; this.lastMousePos = { x: e.clientX, y: e.clientY }; - }); + }; + this.canvas.addEventListener('mousedown', onMouseDown); + this._teardownFns.push(() => this.canvas.removeEventListener('mousedown', onMouseDown)); - window.addEventListener('mousemove', (e) => { + const onMouseMove = (e) => { if (this.isDragging) { const dx = e.clientX - this.lastMousePos.x; const dy = e.clientY - this.lastMousePos.y; @@ -160,13 +171,17 @@ class CanvasRenderer { this.detectHover(graphX, graphY); } - }); + }; + window.addEventListener('mousemove', onMouseMove); + this._teardownFns.push(() => window.removeEventListener('mousemove', onMouseMove)); - window.addEventListener('mouseup', () => { + const onMouseUp = () => { this.isDragging = false; - }); + }; + window.addEventListener('mouseup', onMouseUp); + this._teardownFns.push(() => window.removeEventListener('mouseup', onMouseUp)); - this.canvas.addEventListener('wheel', (e) => { + const onWheel = (e) => { e.preventDefault(); const zoomIntensity = 0.1; const wheel = e.deltaY < 0 ? 1 : -1; @@ -185,13 +200,17 @@ class CanvasRenderer { transform.y = mouseY - graphY * transform.k; this.viewer.renderAll(); - }, { passive: false }); + }; + this.canvas.addEventListener('wheel', onWheel, { passive: false }); + this._teardownFns.push(() => this.canvas.removeEventListener('wheel', onWheel)); - this.canvas.addEventListener('click', (e) => { + const onClick = (e) => { if (this.dragMoved) return; const state = this.viewer.controller.state; this.viewer.controller.handleClick(state.hoveredNodeId, state.hoveredEdge); - }); + }; + this.canvas.addEventListener('click', onClick); + this._teardownFns.push(() => this.canvas.removeEventListener('click', onClick)); } detectHover(graphX, graphY) { diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js index 47aabbb5a30..407191a7bff 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -31,12 +31,14 @@ class FXGraphViewer { constructor(arg1, arg2) { this._listeners = new Map(); this._layoutState = {}; + this._teardownFns = []; this.config = this._normalizeConfig(arg1, arg2); this.containerId = this.config._resolved.root.id || 'fx-viewer-root'; this.rootContainer = this.config._resolved.root; this._injectStyles(); this._buildShell(); + this.config._resolved.slots = this._resolveSlots((this.config.mount && this.config.mount.slots) || {}); this.store = new GraphDataStore(this.config.payload); this.searchEngine = new SearchEngine(this.store); @@ -115,13 +117,7 @@ class FXGraphViewer { const mergedState = this._deepMerge(presetDefaults.state, config.state || {}); const slots = (config.mount && config.mount.slots) || {}; - const resolvedSlots = { - canvas: this._resolveElement(slots.canvas), - toolbar: this._resolveElement(slots.toolbar), - info: this._resolveElement(slots.info), - minimap: this._resolveElement(slots.minimap), - legend: this._resolveElement(slots.legend), - }; + const resolvedSlots = this._resolveSlots(slots); if (Array.isArray(mergedState.activeExtensions)) { mergedState.activeExtensions = mergedState.activeExtensions.slice(); @@ -153,6 +149,16 @@ class FXGraphViewer { return null; } + _resolveSlots(slots) { + return { + canvas: this._resolveElement(slots.canvas), + toolbar: this._resolveElement(slots.toolbar), + info: this._resolveElement(slots.info), + minimap: this._resolveElement(slots.minimap), + legend: this._resolveElement(slots.legend), + }; + } + _presetDefaults(preset) { const split = { layout: { @@ -234,6 +240,12 @@ class FXGraphViewer { return out; } + _addListener(target, eventName, handler, options) { + if (!target || !target.addEventListener || !target.removeEventListener) return; + target.addEventListener(eventName, handler, options); + this._teardownFns.push(() => target.removeEventListener(eventName, handler, options)); + } + _injectStyles() { if (document.getElementById('fx-viewer-styles')) return; const style = document.createElement('style'); @@ -275,10 +287,12 @@ class FXGraphViewer { _buildShell() { const root = this.rootContainer; - root.innerHTML = ''; + const oldWrappers = root.querySelectorAll(':scope > .fx-viewer-wrapper[data-fx-viewer-owned="true"]'); + oldWrappers.forEach((node) => node.remove()); this.wrapper = document.createElement('div'); this.wrapper.className = 'fx-viewer-wrapper'; + this.wrapper.dataset.fxViewerOwned = 'true'; root.appendChild(this.wrapper); this.mainArea = document.createElement('div'); @@ -322,25 +336,27 @@ class FXGraphViewer { if (!this.resizer) return; - this.resizer.addEventListener('mousedown', (e) => { + const onResizerMouseDown = (e) => { if (!this._isSidebarVisible() || this.config.layout?.panels?.sidebar?.resizable === false) return; isResizing = true; this.resizer.classList.add('dragging'); document.body.style.cursor = 'col-resize'; e.preventDefault(); - }); + }; + this._addListener(this.resizer, 'mousedown', onResizerMouseDown); if (this.resizerH) { - this.resizerH.addEventListener('mousedown', (e) => { + const onResizerHMouseDown = (e) => { if (this.config.layout?.panels?.minimap?.resizable === false) return; isResizingH = true; this.resizerH.classList.add('dragging'); document.body.style.cursor = 'row-resize'; e.preventDefault(); - }); + }; + this._addListener(this.resizerH, 'mousedown', onResizerHMouseDown); } - window.addEventListener('mousemove', (e) => { + const onWindowMouseMove = (e) => { if (isResizing) { const containerRect = this.wrapper.getBoundingClientRect(); let newWidth = containerRect.right - e.clientX; @@ -365,9 +381,10 @@ class FXGraphViewer { this.minimapRenderer.generateThumbnail(); this.renderAll(); } - }); + }; + this._addListener(window, 'mousemove', onWindowMouseMove); - window.addEventListener('mouseup', () => { + const onWindowMouseUp = () => { if (isResizing) { isResizing = false; this.resizer.classList.remove('dragging'); @@ -378,16 +395,18 @@ class FXGraphViewer { this.resizerH.classList.remove('dragging'); document.body.style.cursor = ''; } - }); + }; + this._addListener(window, 'mouseup', onWindowMouseUp); - this.resizer.addEventListener('dblclick', () => { + const onResizerDblClick = () => { if (this.config.layout?.panels?.sidebar?.collapsible === false) return; this.sidebar.classList.toggle('collapsed'); requestAnimationFrame(() => { this.canvasRenderer.resize(); this.renderAll(); }); - }); + }; + this._addListener(this.resizer, 'dblclick', onResizerDblClick); } applyLayout(layoutPatch) { @@ -630,8 +649,8 @@ class FXGraphViewer { setUIVisibility(flags = {}) { if (!this.ui) return; - this.ui.setControlVisibility(flags); const prev = this.getState(); + this.ui.setControlVisibility(flags); this.controller.state.uiVisibility = { ...(this.controller.state.uiVisibility || {}), ...flags, @@ -739,8 +758,23 @@ class FXGraphViewer { destroy() { this._listeners.clear(); - if (this.rootContainer) { - this.rootContainer.innerHTML = ''; + if (this.canvasRenderer && this.canvasRenderer.destroy) { + this.canvasRenderer.destroy(); + } + if (this.minimapRenderer && this.minimapRenderer.destroy) { + this.minimapRenderer.destroy(); + } + if (this.ui && this.ui.destroy) { + this.ui.destroy(); + } + while (this._teardownFns.length > 0) { + const off = this._teardownFns.pop(); + try { + off(); + } catch (_) {} + } + if (this.wrapper && this.wrapper.parentNode) { + this.wrapper.parentNode.removeChild(this.wrapper); } } } @@ -773,23 +807,26 @@ class FXGraphCompare { } this._guards = new WeakSet(); this._offs = []; + this._compactSnapshots = new WeakMap(); this._wireSelectionSync(); this._wireStateSync(); this._applyColumns(); - if (this.layout.compact) { - this.viewers.forEach((v) => { - v.setLayout({ panels: { sidebar: { visible: false }, minimap: { visible: false }, info: { visible: false } } }); - }); - } + this._applyCompact(this.layout.compact); } _wireSelectionSync() { this.viewers.forEach((viewer) => { const off = viewer.on('selectionchange', (evt) => { if (!this.sync.selection) return; - if (!evt.nextSelection) return; if (this._guards.has(viewer)) return; + if (!evt.nextSelection) { + this.viewers.forEach((other) => { + if (other === viewer) return; + this._applyGuarded(other, () => other.clearSelection()); + }); + return; + } this.viewers.forEach((other) => { if (other === viewer) return; @@ -850,13 +887,7 @@ class FXGraphCompare { setCompact(compact) { this.layout.compact = !!compact; - this.viewers.forEach((v) => { - if (this.layout.compact) { - v.setLayout({ panels: { sidebar: { visible: false }, minimap: { visible: false }, info: { visible: false } } }); - } else { - v.setLayout({ panels: { sidebar: { visible: true }, minimap: { visible: true }, info: { visible: true } } }); - } - }); + this._applyCompact(this.layout.compact); } setSync(syncPatch = {}) { @@ -879,6 +910,22 @@ class FXGraphCompare { this.container.style.gap = this.container.style.gap || '10px'; } + _applyCompact(compact) { + this.viewers.forEach((viewer) => { + if (compact) { + if (!this._compactSnapshots.has(viewer)) { + this._compactSnapshots.set(viewer, JSON.parse(JSON.stringify(viewer.config.layout || {}))); + } + viewer.setLayout({ panels: { sidebar: { visible: false }, minimap: { visible: false }, info: { visible: false } } }); + return; + } + if (this._compactSnapshots.has(viewer)) { + viewer.setLayout(this._compactSnapshots.get(viewer)); + this._compactSnapshots.delete(viewer); + } + }); + } + destroy() { this._offs.forEach((off) => { try { diff --git a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js index 73d96188e58..58e8b119ecf 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js @@ -62,6 +62,7 @@ class MinimapRenderer { constructor(container, viewer) { this.viewer = viewer; + this._teardownFns = []; const mountPoint = container || this.viewer.sidebar || this.viewer.mainArea; this.container = document.createElement('div'); this.container.className = 'fx-minimap-container'; @@ -80,11 +81,13 @@ class MinimapRenderer { this.isDragging = false; this.resize(); - window.addEventListener('resize', () => { + this._onWindowResize = () => { this.resize(); this.generateThumbnail(); this.render(); - }); + }; + window.addEventListener('resize', this._onWindowResize); + this._teardownFns.push(() => window.removeEventListener('resize', this._onWindowResize)); this.setupEvents(); } @@ -155,18 +158,26 @@ class MinimapRenderer { } setupEvents() { - this.canvas.addEventListener('mousedown', (e) => { + const onMouseDown = (e) => { this.isDragging = true; this.handleDrag(e); - }); - window.addEventListener('mousemove', (e) => { + }; + this.canvas.addEventListener('mousedown', onMouseDown); + this._teardownFns.push(() => this.canvas.removeEventListener('mousedown', onMouseDown)); + + const onMouseMove = (e) => { if (this.isDragging) this.handleDrag(e); - }); - window.addEventListener('mouseup', () => { + }; + window.addEventListener('mousemove', onMouseMove); + this._teardownFns.push(() => window.removeEventListener('mousemove', onMouseMove)); + + const onMouseUp = () => { this.isDragging = false; - }); + }; + window.addEventListener('mouseup', onMouseUp); + this._teardownFns.push(() => window.removeEventListener('mouseup', onMouseUp)); - this.canvas.addEventListener('wheel', (e) => { + const onWheel = (e) => { e.preventDefault(); const zoomIntensity = 0.1; const wheel = e.deltaY < 0 ? 1 : -1; @@ -185,7 +196,9 @@ class MinimapRenderer { transform.y = mouseY - graphY * transform.k; this.viewer.renderAll(); - }, { passive: false }); + }; + this.canvas.addEventListener('wheel', onWheel, { passive: false }); + this._teardownFns.push(() => this.canvas.removeEventListener('wheel', onWheel)); } handleDrag(e) { @@ -276,4 +289,13 @@ class MinimapRenderer { this.ctx.fillStyle = theme.minimapBox; this.ctx.fillRect(mx, my, mw, mh); } + + destroy() { + while (this._teardownFns.length > 0) { + const off = this._teardownFns.pop(); + try { + off(); + } catch (_) {} + } + } } diff --git a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js index 3fd1684bafb..add317de340 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js +++ b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js @@ -70,6 +70,7 @@ class UIManager { this.viewer = viewer; this.controller = viewer.controller; this.options = options; + this._teardownFns = []; this.controls = { toolbar: true, search: true, @@ -179,7 +180,9 @@ class UIManager { this.syncFullscreenButton(); }; this.taskbar.appendChild(this.btnFullscreen); - document.addEventListener('fullscreenchange', () => this.syncFullscreenButton()); + this._onFullscreenChange = () => this.syncFullscreenButton(); + document.addEventListener('fullscreenchange', this._onFullscreenChange); + this._teardownFns.push(() => document.removeEventListener('fullscreenchange', this._onFullscreenChange)); } if (this.controls.clearButton) { @@ -248,7 +251,7 @@ class UIManager { }); } - document.addEventListener('click', (e) => { + this._onDocumentClick = (e) => { if (this.searchContainer && !this.searchContainer.contains(e.target)) { this.closeSearchMenu(); if (this.controller.state.searchCandidates.length > 0) { @@ -258,7 +261,9 @@ class UIManager { if (this.layersContainer && !this.layersContainer.contains(e.target)) { this.layersMenu.style.display = 'none'; } - }); + }; + document.addEventListener('click', this._onDocumentClick); + this._teardownFns.push(() => document.removeEventListener('click', this._onDocumentClick)); this.syncControlsFromState(); this.syncFullscreenButton(); @@ -664,4 +669,16 @@ class UIManager { hideInfoPanel() { this.infoPanel.innerHTML = '
No node selected

Hover or click a node
'; } + + destroy() { + while (this._teardownFns.length > 0) { + const off = this._teardownFns.pop(); + try { + off(); + } catch (_) {} + } + if (this.taskbar && this.taskbar.parentNode) this.taskbar.parentNode.removeChild(this.taskbar); + if (this.legendOverlay && this.legendOverlay.parentNode) this.legendOverlay.parentNode.removeChild(this.legendOverlay); + if (this.infoPanel && this.infoPanel.parentNode) this.infoPanel.parentNode.removeChild(this.infoPanel); + } } From a10c2ee20fc58f93cea2cc79eec1edf82d4efe0e Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 18:36:58 +0800 Subject: [PATCH 08/65] fx_viewer: simplify runtime layer mutation refresh paths --- .../fx_viewer/templates/fx_graph_viewer.js | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js index 407191a7bff..6ba6f55985e 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -672,14 +672,21 @@ class FXGraphViewer { }); } + _refreshAfterLayerMutation({ rebuildMenu = false } = {}) { + this._refreshLayerControls({ rebuildMenu }); + this.controller.setState({}, { source: 'api' }); + } + + _refreshLayerControls({ rebuildMenu = false } = {}) { + if (!this.ui) return; + if (rebuildMenu) this.ui.rebuildLayersMenu(); + this.ui.syncControlsFromState(); + this.ui.renderLegend(); + } + upsertLayer(layerId, layerPayload) { this.store.upsertExtension(layerId, layerPayload); - if (this.ui) { - this.ui.rebuildLayersMenu(); - this.ui.syncControlsFromState(); - this.ui.renderLegend(); - } - this.controller.setState({}, { source: 'api' }); + this._refreshAfterLayerMutation({ rebuildMenu: true }); } removeLayer(layerId) { @@ -688,25 +695,17 @@ class FXGraphViewer { active.delete(layerId); const nextColorBy = this.controller.state.colorBy === layerId ? 'base' : this.controller.state.colorBy; this.controller.setState({ activeExtensions: active, colorBy: nextColorBy }, { source: 'api' }); - if (this.ui) { - this.ui.rebuildLayersMenu(); - this.ui.syncControlsFromState(); - this.ui.renderLegend(); - } + this._refreshLayerControls({ rebuildMenu: true }); } patchLayerNodes(layerId, patchByNodeId) { this.store.patchExtensionNodes(layerId, patchByNodeId); - this.controller.setState({}, { source: 'api' }); + this._refreshAfterLayerMutation(); } setLayerLabel(layerId, label) { this.store.setExtensionLabel(layerId, label); - if (this.ui) { - this.ui.rebuildLayersMenu(); - this.ui.syncControlsFromState(); - this.ui.renderLegend(); - } + this._refreshAfterLayerMutation({ rebuildMenu: true }); } setColorRule(layerId, colorRule) { @@ -720,7 +719,7 @@ class FXGraphViewer { nodeData.fill_color = nextColor; } }); - this.controller.setState({}, { source: 'api' }); + this._refreshAfterLayerMutation(); return; } @@ -737,7 +736,7 @@ class FXGraphViewer { }); if (chosen) nodeData.fill_color = chosen; }); - this.controller.setState({}, { source: 'api' }); + this._refreshAfterLayerMutation(); } } From 7c74bec5b8b43443f31a98856291e5a43426b5dd Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 18:39:17 +0800 Subject: [PATCH 09/65] fx_viewer: align comment and formatting style with existing codebase --- .../examples/demo_fx_viewer_extensions.py | 10 +++++----- .../fx_viewer/templates/canvas_renderer.js | 9 --------- .../fx_viewer/templates/fx_graph_viewer.js | 18 ------------------ .../fx_viewer/templates/graph_data_store.js | 10 ---------- .../fx_viewer/templates/minimap_renderer.js | 9 --------- .../utils/fx_viewer/templates/themes.js | 10 ---------- .../utils/fx_viewer/templates/ui_manager.js | 11 ----------- .../fx_viewer/templates/view_controller.js | 10 ---------- 8 files changed, 5 insertions(+), 82 deletions(-) diff --git a/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py index d1ef5dc059b..89b2356524b 100644 --- a/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py +++ b/backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py @@ -22,11 +22,11 @@ from executorch.backends.qualcomm.utils.fx_viewer import ( - CategoricalColorRule, - FXGraphExporter, - GraphExtension, - GraphNode, - NumericColorRule, + CategoricalColorRule, + FXGraphExporter, + GraphExtension, + GraphNode, + NumericColorRule, ) diff --git a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js index 833ffbd1a86..9636a853581 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js @@ -1,12 +1,3 @@ -/** - * RFC v1 notes: - * - Canvas renderer subscribes to controller/store state and paints the active graph view. - * - Uses `ResizeObserver` + window resize to handle host container size changes. - * - * UX impact: - * - Resizable host panes in harness/embeds immediately trigger crisp canvas redraw. - * - Camera pan/zoom and hover/select feedback remain smooth under dynamic layouts. - */ /** * ============================================================================ * CLASS: CanvasRenderer diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js index 6ba6f55985e..cde2bf584e8 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -1,21 +1,3 @@ -/** - * FXGraphViewer / FXGraphCompare (RFC v1 runtime facade) - * - * Public surface implemented here: - * - Construction: `FXGraphViewer.create(config)` and compatibility constructor. - * - State: `getState`, `setState`, `replaceState`, `batch`. - * - Viewer actions: theme/layer/colorBy/selection/navigation/layout/fullscreen. - * - Runtime layer mutation: upsert/remove/patch/label/color-rule. - * - Events: statechange, selectionchange, themechange, layoutchange, error. - * - Compare orchestration: `FXGraphCompare` with selection/theme/layer/camera sync. - * - * UX impact by command: - * - `setTheme` updates canvas/minimap/UI tokens immediately. - * - `setLayers` / `setColorBy` recompute active graph and legend coloring. - * - `patchLayerNodes` enables slider-style dynamic recoloring by node id. - * - `setLayout` and slot-mounted rendering allow headless or split embedding. - * - `enterFullscreen`/`exitFullscreen` support both API buttons and taskbar control. - */ class FXGraphViewer { static create(config) { return new FXGraphViewer(config); diff --git a/backends/qualcomm/utils/fx_viewer/templates/graph_data_store.js b/backends/qualcomm/utils/fx_viewer/templates/graph_data_store.js index be9b92a02f3..13c137c147a 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/graph_data_store.js +++ b/backends/qualcomm/utils/fx_viewer/templates/graph_data_store.js @@ -1,13 +1,3 @@ -/** - * RFC v1 notes: - * - Stores immutable base graph + mutable extension registry. - * - `computeActiveGraph` materializes render-ready virtual nodes per state. - * - Runtime APIs (`upsertExtension`, `patchExtensionNodes`, etc.) power dynamic overlays. - * - * UX impact: - * - Slider-driven threshold updates can recolor nodes in-place without re-exporting payloads. - * - Layer toggles remain stable because data lives in extension registry, not transient view cache. - */ /** * ============================================================================ * CLASS: GraphDataStore diff --git a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js index 58e8b119ecf..a87a1cb07de 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js @@ -1,12 +1,3 @@ -/** - * RFC v1 notes: - * - Minimap can mount in sidebar or externally provided slot. - * - Thumbnail regenerates on theme/layer changes and minimap resize. - * - * UX impact: - * - Users keep a stable global graph context even in compact/headless layouts. - * - Dragging minimap viewport gives fast navigation for large graphs. - */ /** * ============================================================================ * CLASS: MinimapRenderer diff --git a/backends/qualcomm/utils/fx_viewer/templates/themes.js b/backends/qualcomm/utils/fx_viewer/templates/themes.js index e7f9cc0734e..02ad2f5c30a 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/themes.js +++ b/backends/qualcomm/utils/fx_viewer/templates/themes.js @@ -1,13 +1,3 @@ -/** - * Theme token registry. - * - * Runtime APIs: - * - `FXGraphViewer.setTheme(name)` switches active tokens. - * - `FXGraphViewer.registerTheme(name, tokens)` adds custom themes. - * - * UX impact: - * - Theme changes affect canvas/minimap/info/taskbar/legend consistently. - */ const THEMES = { 'light': { bg: '#ffffff', diff --git a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js index add317de340..f5bcc640bb1 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js +++ b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js @@ -1,15 +1,4 @@ -/** - * RFC v1 notes: - * - UI is a state adapter, not an independent state owner. - * - `syncControlsFromState()` keeps API-driven updates visible in controls. - * - `setControlVisibility()` supports host-level chrome toggling. - * - Optional fullscreen button bridges UI and `viewer.enterFullscreen()/exitFullscreen()`. - * - * UX impact: - * - External JS can change theme/layers/colorBy and users see matching control state. - * - Host can hide toolbar/search/layer UI for focused debugging surfaces. - */ /** * ============================================================================ * CLASS: UIManager diff --git a/backends/qualcomm/utils/fx_viewer/templates/view_controller.js b/backends/qualcomm/utils/fx_viewer/templates/view_controller.js index 48c03cc081a..684348b86b9 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/view_controller.js +++ b/backends/qualcomm/utils/fx_viewer/templates/view_controller.js @@ -1,13 +1,3 @@ -/** - * RFC v1 notes: - * - Controller owns interaction state and camera transform. - * - `setState` triggers canonical recompute/repaint and emits viewer events. - * - Theme/layer/colorBy mutations propagate through store -> minimap -> legend -> canvas. - * - * UX impact: - * - Every interaction path (UI or API) converges on one state pipeline. - * - Search, selection, and camera animation remain consistent across embeds. - */ /** * ============================================================================ * CLASS: ViewerController From 17704ba3b3d15c281f43f6c1c2f63656cf196579 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 18:41:58 +0800 Subject: [PATCH 10/65] fx_viewer: clean docs artifacts and restore merged README guidance --- .../API_REFACTOR_IMPLEMENTATION_LOG.md | 168 --------- .../utils/fx_viewer/JS_COMMAND_UX_TRACE.md | 103 ------ backends/qualcomm/utils/fx_viewer/README.md | 117 ++++-- .../RFC_FX_VIEWER_API_INTERFACE.html | 350 ------------------ .../examples/PER_LAYER_ACCURACY_DEMO_NOTES.md | 150 -------- .../utils/fx_viewer/templates/README.md | 51 ++- 6 files changed, 133 insertions(+), 806 deletions(-) delete mode 100644 backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md delete mode 100644 backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md delete mode 100644 backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html delete mode 100644 backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md diff --git a/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md b/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md deleted file mode 100644 index 842ca5b61e2..00000000000 --- a/backends/qualcomm/utils/fx_viewer/API_REFACTOR_IMPLEMENTATION_LOG.md +++ /dev/null @@ -1,168 +0,0 @@ -# FX Viewer API Refactor Implementation Log - -Date: 2026-03-13 -Owner: Codex - -## Step 0: Scope and guardrails - -Goal: -1. Implement the RFC v1-style JS API surface in current `fx_viewer` templates. -2. Validate with two example families: topological extension demo and per-layer accuracy demo. -3. Build a single HTML testcase harness that reuses one JS bundle and shared payloads. - -Constraints/decisions: -1. Keep payload schema (`base` + `extensions`) unchanged. -2. Allow a compatibility constructor path for now (`new FXGraphViewer(containerId, payload)`), while adding the new factory-style API. -3. Prioritize deterministic behavior and explicit precedence over broad feature scope. - -## Step 1: Architecture audit (completed) - -Files inspected: -1. `templates/fx_graph_viewer.js` -2. `templates/view_controller.js` -3. `templates/ui_manager.js` -4. `templates/graph_data_store.js` -5. `templates/minimap_renderer.js` -6. `exporter.py` -7. `examples/demo_fx_viewer_extensions.py` -8. `examples/demo_per_layer_accuracy_fx.py` - -Key findings: -1. UI synchronization gap exists: external state changes for theme/layer/colorBy are not fully reflected in controls. -2. Layout ownership is hardcoded to split shell; minimap/info mounting is coupled to sidebar. -3. Compare behavior in accuracy demo is achieved by demo-side monkey patching of `selectNode`. -4. Data-layer runtime mutation APIs do not exist (only static extension payload at init). - -Implementation plan selected: -1. Add normalized config model with precedence rules inside `FXGraphViewer`. -2. Add API methods categorized in RFC. -3. Add explicit UI reconciliation (`syncControlsFromState`) in `UIManager`. -4. Add runtime layer mutation helpers in `GraphDataStore`. -5. Add small compare orchestrator class (`FXGraphCompare`). - -## Step 2: v1 API implementation (completed) - -Implemented modules: -1. `templates/fx_graph_viewer.js` -2. `templates/view_controller.js` -3. `templates/ui_manager.js` -4. `templates/graph_data_store.js` -5. `templates/minimap_renderer.js` - -### 2.1 Decisions - -1. Keep constructor compatibility (`new FXGraphViewer(containerId, payload)`) while adding v1 factory (`FXGraphViewer.create(config)`). -2. Implement `preset` as baseline defaults (`split`, `compact`, `headless`, `custom`) merged with explicit `layout`. -3. Enforce slot precedence by resolving mount slots before layout shell usage. -4. Add explicit event system and categorized APIs now; defer strict schema validator to a follow-up. - -### 2.2 Implemented API surface - -1. Construction: - - `FXGraphViewer.create(config)` -2. State/events: - - `getState`, `setState`, `replaceState`, `batch` - - `on`, `off` with `statechange`, `selectionchange`, `themechange`, `layoutchange` -3. Appearance/control: - - `setTheme`, `setLayers`, `setColorBy`, `setUIVisibility`, `setLayout` -4. Navigation: - - `selectNode`, `clearSelection`, `search`, `zoomToFit`, `panToNode`, `animateToNode` -5. Runtime layer mutation: - - `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` -6. Layout/lifecycle: - - `enterFullscreen`, `exitFullscreen`, `destroy` -7. Compare: - - `FXGraphCompare.create({ viewers, layout, sync })` - - `setColumns`, `setCompact`, `setSync`, `destroy` - -### 2.3 UI synchronization fix - -Added `UIManager.syncControlsFromState()` and called it from controller state updates so external JS changes reflect in: -1. Theme select -2. Layer checkboxes -3. Color-by radios -4. Highlight toggle - -This resolves the previously observed drift issue. - -## Step 3: unified testcase harness (completed) - -Created: -1. `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` -2. Generated output: - - `backends/qualcomm/utils/fx_viewer/examples/fx_viewer_api_test_harness.html` - -Harness characteristics: -1. Single shared JS bundle from exporter. -2. Shared payload pool generated once per run. -3. Testcase selector with source panes (HTML input + JS input) and live outcome panel. -4. Runtime log pane for each testcase. - -Included testcases: -1. `topology_split` -2. `headless_slots` -3. `accuracy_dynamic` -4. `compare_sync` - -## Step 4: environment and import path resolution (completed) - -Observed issue: -1. `demo_per_layer_accuracy_fx.py` could import `fx_viewer` from `.venv/site-packages` when source tree path was not resolved correctly. -2. Site-packages copy lacked `templates/*.js`, causing `FileNotFoundError` during `export_html`. - -Fix applied: -1. Updated source-path bootstrap in demos to add repo parent (`~/`) semantics correctly for local source imports. -2. Validated with required run convention: - - source venv - - source QAIRT env - - `set -px PYTHONPATH ~/` - -## Step 5: validation results (completed) - -### 5.1 Static checks - -1. `node --check` passed for all modified JS template files. -2. `python3 -m py_compile` passed for modified Python scripts. - -### 5.2 Runtime checks - -1. `demo_per_layer_accuracy_fx.py` with `--pipeline both` succeeded (fake_quant + qualcomm_ptq): - - Output root: `/tmp/fx_viewer_acc_api_regress6` -2. `demo_fx_viewer_extensions.py --model swin` succeeded: - - Output: `/tmp/fx_viewer_ext_regress/swin_graph_v3_extensions.html` -3. Harness generation succeeded: - - `backends/qualcomm/utils/fx_viewer/examples/fx_viewer_api_test_harness.html` - -## Notes - -1. The warning from backend opinfo adapter is environment/version related and non-blocking for these demos. -2. Follow-up work recommended for strict runtime config validation and more granular compare sync modes. - -## Step 6: testcase bugfixes from review (completed) - -User-reported issues addressed: - -1. `headless_slots` testcase failed with `root mount not found: #case_view`. - - Cause: testcase HTML did not include `#case_view` while JS mounted to that id. - - Fix: wrapped headless grid HTML in `
...
`. - -2. Sidebar splitter between info/minimap appeared at top. - - Cause: `resizerH` was appended before info/minimap order was established. - - Fix: create `resizerH` first but insert it into DOM only after minimap mount, positioned before minimap container so it sits between info panel and minimap. - -3. Outcome panel requested resizable behavior to validate container resize handling. - - Fix: added resizable `#outcomeHost` (`resize: both`) in harness and kept viewer mount in nested `#sandbox`. - - Supporting runtime behavior: `CanvasRenderer` now observes container size via `ResizeObserver` and triggers `resize()`. - -## Step 7: harness UX refinements (completed) - -User-requested updates: -1. Full-screen default harness layout. -2. Run executes edited HTML/JS, not forced template reset. - -Changes in `generate_api_test_harness.py`: -1. Harness page grid changed to full viewport (`height: 100vh`). -2. Body split uses responsive width (`minmax(360px, 36vw) 1fr`). -3. Testcase container heights switched to `height: 100%` to consume outcome pane. -4. Added per-testcase draft storage (`caseDrafts`) so edits persist while switching cases. -5. `runCurrentCase()` now executes `htmlCode.value` and `jsCode.value` directly without calling `renderCaseMeta()`. diff --git a/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md b/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md deleted file mode 100644 index 4b681f99835..00000000000 --- a/backends/qualcomm/utils/fx_viewer/JS_COMMAND_UX_TRACE.md +++ /dev/null @@ -1,103 +0,0 @@ -# JS Command -> UX Trace (Current Runtime) - -This document records how each public command affects runtime behavior and user experience. - -## FXGraphViewer Commands - -1. `create(config)` -- Code: `backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js` -- Effect: resolves mounts, applies preset/layout precedence, initializes renderers/UI. -- UX: viewer appears with split/headless/custom shell and requested controls. - -2. `getState()` -- Effect: returns snapshot (selection, camera, theme, layers, ui visibility, layout state). -- UX: host can inspect current viewer state and build synchronized custom controls. - -3. `setState(patch)` -- Effect: applies patch to controller state; camera/search handled explicitly. -- UX: host can programmatically drive viewer interactions and visual state. - -4. `replaceState(next)` -- Effect: resets canonical state fields and optional camera/search. -- UX: deterministic reset/restore experience for scripted debugging flows. - -5. `setTheme(name)` -- Effect: updates `themeName`, re-themes DOM + canvas/minimap. -- UX: immediate light/dark (or registered custom) switch with consistent UI tokens. - -6. `setLayers(layerIds)` -- Effect: changes active extensions and recomputes virtual graph. -- UX: overlay data appears/disappears without rebuilding page. - -7. `setColorBy(layerId)` -- Effect: color source switches (`base` or extension). -- UX: node colors + legend update to chosen metric/category. - -8. `selectNode(nodeId, opts)` -- Effect: selection state updates, info panel refreshes, optional animation/center. -- UX: focus jumps to selected node with contextual dependency highlighting. - -9. `clearSelection()` -- Effect: clears selection/ancestor/descendant sets. -- UX: graph returns to neutral exploration mode. - -10. `search(query)` -- Effect: updates search candidates and search UI. -- UX: fuzzy lookup and quick navigation through large graphs. - -11. `zoomToFit()` / `panToNode()` / `animateToNode()` -- Effect: updates camera transform. -- UX: smooth camera navigation and context framing. - -12. `setUIVisibility(flags)` -- Effect: hides/shows taskbar/search/layers/theme/legend/fullscreen control. -- UX: host can build focused views (for demos, report sections, compare mode). - -13. `setLayout(layoutPatch)` -- Effect: mutates layout behavior (sidebar visibility/width, minimap visibility/height, etc). -- UX: dynamic transitions between split/compact presentation modes. - -14. `upsertLayer/removeLayer/patchLayerNodes/setLayerLabel/setColorRule` -- Effect: mutates extension registry and refreshes active graph. -- UX: dynamic threshold sliders and runtime overlays work without export roundtrip. - -15. `enterFullscreen/exitFullscreen` -- Effect: browser fullscreen API on root container. -- UX: one-click deep-focus graph inspection; also wired to optional taskbar button. - -16. `on/off` -- Events: `statechange`, `selectionchange`, `themechange`, `layoutchange`, `error`. -- UX: host-side dashboards/custom widgets can stay synchronized. - -## FXGraphCompare Commands - -1. `FXGraphCompare.create({ viewers, layout, sync })` -- Effect: wires compare orchestration and optional container columns. -- UX: side-by-side analysis with centralized controls. - -2. `setColumns(n)` -- Effect: updates compare container grid columns. -- UX: quick N-pane layout changes for different screen sizes. - -3. `setCompact(bool)` -- Effect: toggles compact layout (sidebar/minimap/info visibility in viewers). -- UX: maximizes graph canvas area during compare. - -4. `setSync({ selection, camera, theme, layers })` -- Effect: controls cross-view propagation dimensions. -- UX: can lock only needed dimensions (e.g., selection only). - -## Key Internal Command Paths - -1. UI commands -> controller -- Files: `ui_manager.js`, `view_controller.js` -- UX: taskbar controls always use same state pipeline as external JS. - -2. Controller state -> store recompute -- File: `view_controller.js` -> `graph_data_store.js` -- UX: layer/color changes stay consistent in canvas, legend, minimap, info panel. - -3. Container resize -> canvas resize -- File: `canvas_renderer.js` -- Mechanism: `ResizeObserver` + window resize. -- UX: resizable host panes keep rendering sharp and correctly scaled. diff --git a/backends/qualcomm/utils/fx_viewer/README.md b/backends/qualcomm/utils/fx_viewer/README.md index d1b7aebacf0..4e2b61194b4 100644 --- a/backends/qualcomm/utils/fx_viewer/README.md +++ b/backends/qualcomm/utils/fx_viewer/README.md @@ -1,6 +1,6 @@ # fx_viewer -`fx_viewer` exports FX graphs to interactive HTML and provides a state-driven JS runtime. +`fx_viewer` exports FX graphs to interactive HTML and provides an embeddable JavaScript runtime. ## What It Provides @@ -12,16 +12,21 @@ Python side: JS side: 1. Canvas graph + minimap + info panel + search. -2. Layer toggles and color-by. -3. State-driven API for embedding, compare, fullscreen, and runtime layer mutation. +2. Layer toggles and color-by controls. +3. State-driven API for embedding, compare mode, fullscreen, and runtime layer mutation. -## RFC and Current Status +## Quick Start -Primary RFC: -1. `backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md` +From repo root: -Implementation status: -1. `backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md` +```bash +source .venv/bin/activate +python backends/qualcomm/utils/fx_viewer/examples/demo_fx_viewer_extensions.py --model both +``` + +Outputs: +1. `swin_graph_v3_extensions.html` +2. `llama_graph_v3_extensions.html` ## Python API @@ -50,51 +55,105 @@ Main exporter methods: ## JS API (Runtime) -Core: +Construction: 1. `FXGraphViewer.create(config)` -2. `getState`, `setState`, `replaceState`, `batch` -3. `on`, `off` +2. Compatibility constructor: `new FXGraphViewer(containerId, payload)` -Actions: +State/events: +1. `getState`, `setState`, `replaceState`, `batch` +2. `on`, `off` + +Viewer actions: 1. `setTheme`, `setLayers`, `setColorBy` 2. `selectNode`, `clearSelection`, `search` 3. `zoomToFit`, `panToNode`, `animateToNode` 4. `setUIVisibility`, `setLayout` -5. `enterFullscreen`, `exitFullscreen` +5. `enterFullscreen`, `exitFullscreen`, `destroy` Runtime layer mutation: 1. `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` Compare: -1. `FXGraphCompare.create(...)` +1. `FXGraphCompare.create({ viewers, layout, sync })` 2. `setColumns`, `setCompact`, `setSync`, `destroy` +## Canonical Data Contract + +Top-level payload: +1. `base`: `{ legend, nodes, edges }` +2. `extensions`: map keyed by extension id + +`base.nodes[]` fields: +1. `id`, `label`, `x`, `y`, `width`, `height` +2. `info`: metadata used by search/info panel +3. `tooltip`: base tooltip lines +4. `fill_color` (optional) + +`base.edges[]` fields: +1. `v`, `w` +2. `points` (optional routed polyline) + +## Extension Authoring Guide + +Key contract: +1. Add extension data explicitly with `add_node_data(node_id, data)`. +2. Formatter input is exactly that stored `data` dictionary. +3. Formatters must return `list[str]`. + +What formatters do not receive implicitly: +1. Full FX node object. +2. Base graph `info` fields. +3. Global graph context. + +If you need base attributes (for example `target`, `op`) in extension label/tooltip, +copy them into extension data before formatter use. + +## Color Rules + +Available rules: +1. `CategoricalColorRule(attribute, color_map=None)` +2. `NumericColorRule(attribute, cmap="viridis", handle_outliers=True)` + +Rule selection: +1. Use categorical for discrete semantic labels. +2. Use numeric for continuous measured metrics. +3. Keep `handle_outliers=True` for noisy distributions. +4. For rank/index-like metrics, set `handle_outliers=False`. + ## Unified API Harness -Generator: -1. `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` +Files: +1. Generator: `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` +2. Template: `backends/qualcomm/utils/fx_viewer/examples/harness_template.html` +3. Testcases: `backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py` +4. Testcase reference: `backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md` -Template + testcase catalog: -1. `backends/qualcomm/utils/fx_viewer/examples/harness_template.html` -2. `backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py` +Generate harnesses: + +```bash +source .venv/bin/activate +export PYTHONPATH=~/:$PYTHONPATH +python backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py +``` Generated outputs: 1. `fx_viewer_api_test_harness_portable.html` 2. `fx_viewer_api_test_harness_qualcomm.html` -Testcase reference: -1. `backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md` +## Testing + +Contract tests: +1. `tests/test_exporter_contract.py` -## Recommended Run (bash) +Run: ```bash -source /home/boyucwsl/executorch/.venv/bin/activate -source /home/boyucwsl/executorch/qairt/2.37.0.250724/bin/envsetup.sh -export PYTHONPATH=~/:$PYTHONPATH -python backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py +source .venv/bin/activate +pytest -q tests/test_exporter_contract.py ``` -## JS Internals +## References -See: -1. `backends/qualcomm/utils/fx_viewer/templates/README.md` +1. API RFC: `backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.md` +2. Implementation status: `backends/qualcomm/utils/fx_viewer/RFC_API_IMPLEMENTATION_STATUS.md` +3. JS runtime internals: `backends/qualcomm/utils/fx_viewer/templates/README.md` diff --git a/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html b/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html deleted file mode 100644 index f3ab9b8fb0f..00000000000 --- a/backends/qualcomm/utils/fx_viewer/RFC_FX_VIEWER_API_INTERFACE.html +++ /dev/null @@ -1,350 +0,0 @@ - - - - - - -RFC_FX_VIEWER_API_INTERFACE.html - - - - - - - -

RFC: fx_viewer API Interface v1 (Breaking)

- -

Date: 2026-03-13
-Status: Draft for review
-Owners: Qualcomm Executorch debugging team
-Scope: backends/qualcomm/utils/fx_viewer public JavaScript API and embedding contract

- -

1. Abstract

- -

This RFC defines a breaking v1 API for fx_viewer so it can be used consistently in:

- -
    -
  1. Standalone graph/accuracy demos.
  2. -
  3. Observatory GraphView integration.
  4. -
- -

The v1 design is state-driven, layout-composable, compare-native, and explicit about ownership/override rules.

- -

2. Background

- -

2.1 What is fx_viewer

- -

fx_viewer renders FX graph payloads (base graph + optional extension layers) with interactive controls (selection, search, theme, color-by, minimap, info panel).

- -

2.2 What is Observatory (debugging_utils)

- -

Observatory captures and organizes multiple debug records across compilation/runtime stages using a lens framework and report UI.

- -

2.3 Why this RFC

- -

Current integration works but requires ad-hoc JS/DOM coupling for layout, compare sync, and dynamic per-layer coloring. v1 makes these capabilities first-class.

- -

3. Problem Statement

- -
    -
  1. External API updates can desync built-in controls.
  2. -
  3. Layout shell and graph core are tightly coupled.
  4. -
  5. Host custom controls (sliders/jump links) lack stable contracts.
  6. -
  7. Compare orchestration is duplicated per demo.
  8. -
- -

4. Goals

- -
    -
  1. Single source of truth state.
  2. -
  3. Unified layout config with strict precedence rules.
  4. -
  5. Explicit host/viewer ownership rules.
  6. -
  7. Clear JS API categories.
  8. -
  9. First-class runtime data/layer mutation.
  10. -
  11. First-class N-view compare + sync.
  12. -
- -

5. Non-Goals

- -
    -
  1. Backward compatibility with old APIs.
  2. -
  3. Observatory backend schema details.
  4. -
  5. Large-graph performance redesign.
  6. -
- -

6. Unified Config Model

- -

FXGraphViewer.create(...) accepts one normalized config shape.

- -

ts -const viewer = FXGraphViewer.create({ - payload, - mount: { - root: "#graph-root", - slots: { - canvas: "#canvas-slot", - toolbar: "#toolbar-slot", - info: "#info-slot", - minimap: "#minimap-slot", - legend: "#legend-slot", - }, - }, - layout: { - preset: "split", // split | compact | headless | custom - panels: { - sidebar: { visible: true, width: 420, resizable: true, collapsible: true }, - info: { visible: true, dock: "sidebar" }, - minimap: { visible: true, dock: "sidebar", height: 240, resizable: true }, - legend: { visible: true, dock: "toolbar" }, - }, - fullscreen: { enabled: true, button: true }, - }, - ui: { - controls: { - toolbar: true, - search: true, - layers: true, - colorBy: true, - theme: true, - minimapToggle: true, - zoomButtons: true, - clearSelection: true, - }, - }, - state: { - theme: "light", - activeExtensions: ["per_layer_accuracy"], - colorBy: "per_layer_accuracy", - selectedNodeId: null, - searchQuery: "", - highlightAncestors: true, - }, -}); -

- -

7. Precedence and Ownership Rules

- -

7.1 Precedence (highest to lowest)

- -
    -
  1. Explicit mount.slots.* (placement owner).
  2. -
  3. Explicit layout.* fields (behavior/visibility/dock defaults).
  4. -
  5. layout.preset defaults.
  6. -
  7. Internal built-in defaults.
  8. -
- -

Interpretation:

- -
    -
  1. If mount.slots.info is given, info panel mounts there even if preset is split.
  2. -
  3. Preset cannot override explicit slot placement.
  4. -
  5. Explicit layout fields override preset behavior values.
  6. -
- -

7.2 Ownership

- -
    -
  1. Host owns slot nodes (#info-slot, #minimap-slot, etc).
  2. -
  3. Viewer never overwrites host attributes/classes/styles on slot nodes.
  4. -
  5. Viewer creates and owns child nodes mounted inside slot nodes.
  6. -
  7. If slot is absent, viewer creates and owns container nodes in preset/custom layout shell.
  8. -
- -

7.3 HTML attribute behavior

- -
    -
  1. Host HTML attributes are preserved.
  2. -
  3. Viewer style/classes apply to viewer-owned descendants.
  4. -
  5. Host can style slot containers; viewer guarantees stable mount points.
  6. -
- -

8. Presets

- -

preset is a layout baseline recipe only.

- -
    -
  1. split: canvas + sidebar defaults.
  2. -
  3. compact: minimal chrome, graph-first.
  4. -
  5. headless: no shell; host provides slots.
  6. -
  7. custom: no defaults; all structure explicit.
  8. -
- -

Rule: preset fills missing layout values only.

- -

9. Public JS API (Categorized)

- -

9.1 State APIs

- -

ts -viewer.getState(): ViewerState; -viewer.setState(patch: Partial<ViewerState>, opts?: { source?: "api" | "ui" | "system" }): void; -viewer.replaceState(next: ViewerState, opts?: { source?: "api" | "ui" | "system" }): void; -viewer.batch(fn: () => void): void; // coalesce redraw/events -

- -

9.2 Data/Layer Mutation APIs (runtime)

- -

ts -viewer.upsertLayer(layerId: string, layerPayload: LayerPayload): void; -viewer.removeLayer(layerId: string): void; -viewer.patchLayerNodes(layerId: string, patchByNodeId: Record<string, NodePatch>): void; -viewer.setColorRule(layerId: string, colorRule: ColorRule): void; -viewer.setLayerLabel(layerId: string, label: string): void; -

- -

Use these for slider-driven threshold coloring or dynamic labels. Do not mutate transient renderer-only state.

- -

9.3 Selection/Camera APIs

- -

ts -viewer.selectNode(nodeId: string, opts?: { animate?: boolean; center?: boolean; durationMs?: number }): void; -viewer.clearSelection(): void; -viewer.panToNode(nodeId: string): void; -viewer.animateToNode(nodeId: string, opts?: { durationMs?: number; easing?: string; k?: number }): void; -viewer.zoomToFit(): void; -

- -

9.4 Appearance/UI APIs

- -

ts -viewer.setTheme(themeName: string): void; -viewer.setLayers(layerIds: string[]): void; -viewer.setColorBy(layerId: string): void; -viewer.setUIVisibility(flags: Partial<UIVisibility>): void; -

- -

9.5 Layout APIs

- -

ts -viewer.setLayout(layoutPatch: Partial<LayoutConfig>): void; -viewer.enterFullscreen(): void; -viewer.exitFullscreen(): void; -

- -

9.6 Lifecycle and Events

- -

ts -viewer.on("statechange", (e) => {}); -viewer.on("selectionchange", (e) => {}); -viewer.on("layoutchange", (e) => {}); -viewer.on("themechange", (e) => {}); -viewer.on("error", (e) => {}); -viewer.destroy(): void; -

- -

All events include source, timestamp, and relevant previous/next snapshots.

- -

10. Runtime Mutation Semantics (Dynamic threshold use case)

- -

Goal: support real-time slider callbacks that update node color/severity by node id.

- -

Rules:

- -
    -
  1. Mutate layer registry (upsertLayer, patchLayerNodes, setColorRule), not transient active node cache.
  2. -
  3. Layer toggle off/on must preserve patched values.
  4. -
  5. setColorBy(layerId) should immediately use latest patched values.
  6. -
  7. Prefer batch(...) for smooth updates.
  8. -
- -

Example:

- -

ts -slider.oninput = (threshold) => { - const patch = computePatch(threshold); // nodeId -> { value, color, label } - viewer.batch(() => { - viewer.patchLayerNodes("per_layer_accuracy", patch); - viewer.setColorBy("per_layer_accuracy"); - }); -}; -

- -

11. UI Synchronization Contract

- -

Built-in UI is a pure state adapter.

- -
    -
  1. API updates must always reflect in UI controls.
  2. -
  3. UI interaction must always dispatch state mutations, never direct renderer mutations.
  4. -
  5. No hidden UI-only state for theme/layers/colorBy/search.
  6. -
- -

This closes the current drift issue.

- -

12. Compare API

- -

```ts -const compare = FXGraphCompare.create({ - viewers: [viewerA, viewerB, viewerC], - layout: { columns: 2, compact: true }, - sync: { selection: true, camera: false, theme: false, layers: false }, -});

- -

compare.setColumns(3); -compare.setSync({ selection: true, camera: true }); -compare.destroy(); -```

- -

Semantics:

- -
    -
  1. Source-guarded propagation avoids loops.
  2. -
  3. Selection sync applies only when target viewer has the node id.
  4. -
  5. Viewers remain independently usable outside compare orchestration.
  6. -
- -

13. Breaking Changes

- -

Removed in v1:

- -
    -
  1. Legacy ad-hoc constructor mutation paths.
  2. -
  3. Direct DOM poking as integration contract.
  4. -
  5. Non-state-backed control update paths.
  6. -
- -

14. Implementation Plan

- -
    -
  1. Add ViewerStateStore (schema + reducer/actions + event bus).
  2. -
  3. Add LayoutManager (preset resolution + ownership + docking + splitters).
  4. -
  5. Refactor UIManager into state subscriber/dispatcher.
  6. -
  7. Add LayerRegistry mutation APIs.
  8. -
  9. Add FXGraphCompare orchestrator.
  10. -
  11. Migrate templates to v1-only paths.
  12. -
- -

15. Testing Plan

- -
    -
  1. Precedence tests: slot/layout/preset conflict resolution.
  2. -
  3. Ownership tests: host attributes preserved; viewer children mounted correctly.
  4. -
  5. UI sync tests: API-driven theme/layer/colorBy reflected in controls.
  6. -
  7. Runtime mutation tests: slider-style patch updates persist across layer toggles.
  8. -
  9. Compare tests: sync propagation and loop prevention.
  10. -
  11. E2E demo tests: standalone accuracy view + split/compact/headless embeddings.
  12. -
- -

16. Risks and Mitigations

- -
    -
  1. Risk: complexity increase from flexibility. -Mitigation: strict normalized config + validation errors.
  2. -
  3. Risk: regressions during migration. -Mitigation: interaction snapshot coverage for default exports.
  4. -
  5. Risk: unclear host/viewer responsibilities. -Mitigation: explicit ownership rules and precedence docs (Sections 7 and 6).
  6. -
- -

17. Expected Outcome

- -
    -
  1. Lens and demo authors get one clear API model.
  2. -
  3. Dynamic per-layer coloring/labels from JS becomes official and stable.
  4. -
  5. Layout composition is powerful without DOM hacks.
  6. -
  7. Compare/sync capabilities are reusable instead of reimplemented.
  8. -
- - - diff --git a/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md b/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md deleted file mode 100644 index 66abbcbbb4e..00000000000 --- a/backends/qualcomm/utils/fx_viewer/examples/PER_LAYER_ACCURACY_DEMO_NOTES.md +++ /dev/null @@ -1,150 +0,0 @@ -# FX Viewer Per-Layer Accuracy Demo Notes - -## Goal -Build a standalone (`fx_viewer`-only, no Observatory UI) demo that: -1. Captures per-layer outputs from two FX graphs. -2. Matches layers across stages using debug handles. -3. Computes per-layer accuracy metrics. -4. Visualizes severity on graph nodes (red = worse). -5. Supports backend-agnostic and Qualcomm PTQ workflows. -6. Uses worst-sample-first debugging workflow. - -## Implemented Files -- Demo script: `backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py` -- Compile-flow fix: `exir/program/_program.py` - -## Compile-Only Fix Applied -### Issue -`examples.qualcomm.oss_scripts.swin_transformer --compile_only` failed with: -- `AttributeError: ...debugging_utils.observatory has no attribute 'collect'` - -### Root cause -`exir/program/_program.py` imported the module and called `observatory.collect(...)` instead of class API `Observatory.collect(...)`. - -### Patch -In `exir/program/_program.py`, changed: -- `from ... import observatory; observatory.collect(...)` - -To: -- `from ... import Observatory; Observatory.collect(...)` - -### Result -The compile-only command now completes successfully. - -## fx_viewer Import Path Decision -Use canonical import first: -- `from executorch.backends.qualcomm.utils.fx_viewer import ...` - -Fallback to local package path only when Qualcomm init is unavailable: -- add `backends/qualcomm/utils` to `sys.path` -- import `fx_viewer` - -This keeps Qualcomm path clean while preserving backend-agnostic usability. - -## Demo Improvements Implemented - -### 1) Split-view UX + n-split-capable layout -`compare_side_by_side.html` is now generated by a generic multi-panel renderer: -- CSS grid supports N panels. -- Toolbar includes: - - sync selection toggle - - compact mode toggle (hides each embedded viewer sidebar for split mode) - - columns selector (1-4) - -### 2) Smooth transition on synced node selection -When sync is enabled and a node is selected in one panel: -- other panels call `controller.selectNode(nodeId)` -- then `controller.animateToNode(nodeId)` - -This uses fx_viewer’s smooth camera transition API. - -### 3) Worst-sample-first per-layer debug workflow -Per pipeline: -1. Run E2E output comparison on multiple samples (`--num-samples`). -2. Compute sample drop score from output error/cosine. -3. Pick worst sample index. -4. Capture per-layer intermediates only on that worst sample. - -This aligns per-layer analysis with the most obvious end-to-end degradation case. - -### 4) Severity-based coloring (red bad, lighter okay) -Per-layer severity is computed as: -- `severity_score = max_abs_err + max(0, 1 - cosine_similarity)` - -Graph extension uses: -- `NumericColorRule(attribute="severity_score", cmap="reds")` - -So darker/stronger red means larger performance drop. - -## Debug Handle + Matching Behavior -### APIs used -- `devtools/inspector/_intermediate_output_capturer.py` -- `devtools/inspector/_inspector_utils.py:get_aot_debug_handle_to_op_name_mapping` -- `exir/passes/debug_handle_generator_pass.py:generate_missing_debug_handles` - -### Notes -- Capturer keys outputs by debug-handle tuples. -- For transformed `GraphModule` (not `ExportedProgram`), script ensures handles with `_ensure_graph_module_debug_handles(...)`. - -### Matching strategy -1. Exact debug-handle intersection. -2. Fallback by node name. - -## Validation Runs - -## 1) Backend-agnostic demo -```bash -source .venv/bin/activate -export PYTHONPATH=~/ -python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ - --model toy --pipeline fake_quant --num-samples 6 --output-dir /tmp/fx_demo_toy2 -``` -Result: success. - -## 2) Qualcomm PTQ demo -```bash -source qairt/2.37.0.250724/bin/envsetup.sh -source .venv/bin/activate -export PYTHONPATH=~/ -python backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py \ - --model toy --pipeline qualcomm_ptq --soc-model SM8650 --backend htp \ - --num-samples 6 --calibration-steps 2 --output-dir /tmp/fx_demo_qnn_toy2 -``` -Result: success. - -## 3) Requested compile-only Swin command (after fix) -```bash -source qairt/2.37.0.250724/bin/envsetup.sh -source .venv/bin/activate -export PYTHONPATH=~/ -python -m examples.qualcomm.oss_scripts.swin_transformer -m SM8650 -b ./build-android \ - --dataset imagenet-mini-val/val/ -H mlgtw-linux -s bebcca9b -a swin_transformer_Lanai \ - --seed 1126 --compile_only -``` -Result: success (no `observatory.collect` attribute error). - -## ETRecord Graph Save Summary -From `devtools/etrecord/_etrecord.py`, ETRecord stores: -- edge dialect exported program(s) -- optional exported program -- optional graph map -- debug handle map -- delegate map -- instruction-to-num-outs map -- optional reference outputs and representative inputs -- export graph id - -This is sufficient for future multi-stage replay/compare workflows. - -## Where to Insert Future Graph Capture (Qualcomm compile path) -In `examples/qualcomm/utils.py:build_executorch_binary`, natural insertion points are: -1. post-export (float FX graph) -2. post-`convert_pt2e` (quantized FX graph) -3. pre/post `to_edge_transform_and_lower_to_qnn` (edge transform stages) - -For this demo, scope is intentionally FX graphs only. - -## Current Limitations -- Sample generation is random tensor-based (no dataset loader in this script yet). -- Matching fallback is node-name only (no topology matcher yet). -- Compare page is N-panel capable, but current pipeline exports 2 primary panels by default. diff --git a/backends/qualcomm/utils/fx_viewer/templates/README.md b/backends/qualcomm/utils/fx_viewer/templates/README.md index dbabbf79f8e..de06b6a5251 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/README.md +++ b/backends/qualcomm/utils/fx_viewer/templates/README.md @@ -1,4 +1,4 @@ -# fx_viewer JS Runtime (RFC v1) +# fx_viewer JS Runtime This folder contains the browser runtime used by `FXGraphExporter`. @@ -11,10 +11,22 @@ This folder contains the browser runtime used by `FXGraphExporter`. 5. `UIManager` is a state adapter for taskbar/search/layers/info/legend controls. 6. `FXGraphCompare` orchestrates multi-view compare and synchronization. +## Files and Responsibilities + +1. `themes.js`: shared theme tokens (`THEMES`). +2. `graph_data_store.js`: payload normalization, topology cache, virtual-node composition. +3. `search_engine.js`: fuzzy search over active nodes. +4. `view_controller.js`: state machine and interaction orchestration. +5. `canvas_renderer.js`: primary graph rendering + canvas interactions. +6. `minimap_renderer.js`: minimap rendering + minimap navigation. +7. `ui_manager.js`: taskbar/search/layers/info panel/legend DOM. +8. `fx_graph_viewer.js`: top-level facade (`FXGraphViewer`) and compare orchestration (`FXGraphCompare`). + ## Public API (Implemented) Construction: 1. `FXGraphViewer.create(config)` +2. Compatibility constructor: `new FXGraphViewer(containerId, payload)` State/events: 1. `getState`, `setState`, `replaceState`, `batch` @@ -41,12 +53,33 @@ Compare: 3. `layout.preset` fills missing values. 4. Built-in defaults are last fallback. -## Key UX Behaviors +## Payload Contract + +Runtime input payload: -1. API-driven changes reflect in UI controls (`theme/layers/colorBy` sync). -2. Host container resize triggers canvas resize (`ResizeObserver`). -3. Headless slots support custom HTML controls around GraphView. -4. Optional taskbar fullscreen button is enabled via `layout.fullscreen.button`. +```js +{ + base: { + legend: [{ label, color }], + nodes: [{ id, label, x, y, width, height, info, tooltip, fill_color? }], + edges: [{ v, w, points? }] + }, + extensions: { + [extId]: { + name: string, + legend: [{ label, color }], + nodes: { + [nodeId]: { + info?: object, + tooltip?: string[], + label_append?: string[], + fill_color?: string + } + } + } + } +} +``` ## Script Load Order @@ -58,3 +91,9 @@ Compare: 6. `minimap_renderer.js` 7. `ui_manager.js` 8. `fx_graph_viewer.js` + +## Maintenance Notes + +1. Keep module boundaries strict; route orchestration through controller/facade. +2. Preserve payload compatibility when adding UI/runtime features. +3. If state shape changes, update docs and relevant contracts in this folder. From 9a212c60a7a29022b0cd22b89dad0adce77cca33 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 19:47:55 +0800 Subject: [PATCH 11/65] fx_viewer: reduce runtime boilerplate with shared listener and UI helpers --- .../utils/fx_viewer/templates/README.md | 25 +---- .../fx_viewer/templates/canvas_renderer.js | 25 ++--- .../fx_viewer/templates/fx_graph_viewer.js | 25 ++--- .../fx_viewer/templates/minimap_renderer.js | 22 +--- .../utils/fx_viewer/templates/themes.js | 23 ++++ .../utils/fx_viewer/templates/ui_manager.js | 103 +++++++++--------- 6 files changed, 100 insertions(+), 123 deletions(-) diff --git a/backends/qualcomm/utils/fx_viewer/templates/README.md b/backends/qualcomm/utils/fx_viewer/templates/README.md index de06b6a5251..1a1afd2c736 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/README.md +++ b/backends/qualcomm/utils/fx_viewer/templates/README.md @@ -22,29 +22,12 @@ This folder contains the browser runtime used by `FXGraphExporter`. 7. `ui_manager.js`: taskbar/search/layers/info panel/legend DOM. 8. `fx_graph_viewer.js`: top-level facade (`FXGraphViewer`) and compare orchestration (`FXGraphCompare`). -## Public API (Implemented) +## Public API -Construction: -1. `FXGraphViewer.create(config)` -2. Compatibility constructor: `new FXGraphViewer(containerId, payload)` +The canonical API reference is maintained in: +1. `backends/qualcomm/utils/fx_viewer/README.md` (`JS API (Runtime)` section) -State/events: -1. `getState`, `setState`, `replaceState`, `batch` -2. `on`, `off` - -Viewer actions: -1. `setTheme`, `setLayers`, `setColorBy` -2. `selectNode`, `clearSelection`, `search` -3. `zoomToFit`, `panToNode`, `animateToNode` -4. `setUIVisibility`, `setLayout` -5. `enterFullscreen`, `exitFullscreen`, `destroy` - -Layer mutation: -1. `upsertLayer`, `removeLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule` - -Compare: -1. `FXGraphCompare.create({ viewers, layout, sync })` -2. `setColumns`, `setCompact`, `setSync`, `destroy` +This file focuses on runtime internals, file responsibilities, and script load order. ## Config Precedence diff --git a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js index 9636a853581..98ec9eddd53 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/canvas_renderer.js @@ -99,8 +99,7 @@ class CanvasRenderer { this.resize(); this._onWindowResize = () => this.resize(); - window.addEventListener('resize', this._onWindowResize); - this._teardownFns.push(() => window.removeEventListener('resize', this._onWindowResize)); + fxOn(this._teardownFns, window, 'resize', this._onWindowResize); this._resizeObserver = null; if (typeof ResizeObserver !== 'undefined') { this._resizeObserver = new ResizeObserver(() => this.resize()); @@ -123,12 +122,7 @@ class CanvasRenderer { this._resizeObserver.disconnect(); this._resizeObserver = null; } - while (this._teardownFns.length > 0) { - const off = this._teardownFns.pop(); - try { - off(); - } catch (_) {} - } + fxOffAll(this._teardownFns); } setupEvents() { @@ -137,8 +131,7 @@ class CanvasRenderer { this.dragMoved = false; this.lastMousePos = { x: e.clientX, y: e.clientY }; }; - this.canvas.addEventListener('mousedown', onMouseDown); - this._teardownFns.push(() => this.canvas.removeEventListener('mousedown', onMouseDown)); + fxOn(this._teardownFns, this.canvas, 'mousedown', onMouseDown); const onMouseMove = (e) => { if (this.isDragging) { @@ -163,14 +156,12 @@ class CanvasRenderer { this.detectHover(graphX, graphY); } }; - window.addEventListener('mousemove', onMouseMove); - this._teardownFns.push(() => window.removeEventListener('mousemove', onMouseMove)); + fxOn(this._teardownFns, window, 'mousemove', onMouseMove); const onMouseUp = () => { this.isDragging = false; }; - window.addEventListener('mouseup', onMouseUp); - this._teardownFns.push(() => window.removeEventListener('mouseup', onMouseUp)); + fxOn(this._teardownFns, window, 'mouseup', onMouseUp); const onWheel = (e) => { e.preventDefault(); @@ -192,16 +183,14 @@ class CanvasRenderer { this.viewer.renderAll(); }; - this.canvas.addEventListener('wheel', onWheel, { passive: false }); - this._teardownFns.push(() => this.canvas.removeEventListener('wheel', onWheel)); + fxOn(this._teardownFns, this.canvas, 'wheel', onWheel, { passive: false }); const onClick = (e) => { if (this.dragMoved) return; const state = this.viewer.controller.state; this.viewer.controller.handleClick(state.hoveredNodeId, state.hoveredEdge); }; - this.canvas.addEventListener('click', onClick); - this._teardownFns.push(() => this.canvas.removeEventListener('click', onClick)); + fxOn(this._teardownFns, this.canvas, 'click', onClick); } detectHover(graphX, graphY) { diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js index cde2bf584e8..b853b272011 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -222,12 +222,6 @@ class FXGraphViewer { return out; } - _addListener(target, eventName, handler, options) { - if (!target || !target.addEventListener || !target.removeEventListener) return; - target.addEventListener(eventName, handler, options); - this._teardownFns.push(() => target.removeEventListener(eventName, handler, options)); - } - _injectStyles() { if (document.getElementById('fx-viewer-styles')) return; const style = document.createElement('style'); @@ -325,7 +319,7 @@ class FXGraphViewer { document.body.style.cursor = 'col-resize'; e.preventDefault(); }; - this._addListener(this.resizer, 'mousedown', onResizerMouseDown); + fxOn(this._teardownFns, this.resizer, 'mousedown', onResizerMouseDown); if (this.resizerH) { const onResizerHMouseDown = (e) => { @@ -335,7 +329,7 @@ class FXGraphViewer { document.body.style.cursor = 'row-resize'; e.preventDefault(); }; - this._addListener(this.resizerH, 'mousedown', onResizerHMouseDown); + fxOn(this._teardownFns, this.resizerH, 'mousedown', onResizerHMouseDown); } const onWindowMouseMove = (e) => { @@ -364,7 +358,7 @@ class FXGraphViewer { this.renderAll(); } }; - this._addListener(window, 'mousemove', onWindowMouseMove); + fxOn(this._teardownFns, window, 'mousemove', onWindowMouseMove); const onWindowMouseUp = () => { if (isResizing) { @@ -378,7 +372,7 @@ class FXGraphViewer { document.body.style.cursor = ''; } }; - this._addListener(window, 'mouseup', onWindowMouseUp); + fxOn(this._teardownFns, window, 'mouseup', onWindowMouseUp); const onResizerDblClick = () => { if (this.config.layout?.panels?.sidebar?.collapsible === false) return; @@ -388,7 +382,7 @@ class FXGraphViewer { this.renderAll(); }); }; - this._addListener(this.resizer, 'dblclick', onResizerDblClick); + fxOn(this._teardownFns, this.resizer, 'dblclick', onResizerDblClick); } applyLayout(layoutPatch) { @@ -748,12 +742,7 @@ class FXGraphViewer { if (this.ui && this.ui.destroy) { this.ui.destroy(); } - while (this._teardownFns.length > 0) { - const off = this._teardownFns.pop(); - try { - off(); - } catch (_) {} - } + fxOffAll(this._teardownFns); if (this.wrapper && this.wrapper.parentNode) { this.wrapper.parentNode.removeChild(this.wrapper); } @@ -897,7 +886,7 @@ class FXGraphCompare { if (!this._compactSnapshots.has(viewer)) { this._compactSnapshots.set(viewer, JSON.parse(JSON.stringify(viewer.config.layout || {}))); } - viewer.setLayout({ panels: { sidebar: { visible: false }, minimap: { visible: false }, info: { visible: false } } }); + viewer.setLayout(FX_COMPARE_COMPACT_LAYOUT_PATCH); return; } if (this._compactSnapshots.has(viewer)) { diff --git a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js index a87a1cb07de..4c6e16b83c6 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/minimap_renderer.js @@ -77,8 +77,7 @@ class MinimapRenderer { this.generateThumbnail(); this.render(); }; - window.addEventListener('resize', this._onWindowResize); - this._teardownFns.push(() => window.removeEventListener('resize', this._onWindowResize)); + fxOn(this._teardownFns, window, 'resize', this._onWindowResize); this.setupEvents(); } @@ -153,20 +152,17 @@ class MinimapRenderer { this.isDragging = true; this.handleDrag(e); }; - this.canvas.addEventListener('mousedown', onMouseDown); - this._teardownFns.push(() => this.canvas.removeEventListener('mousedown', onMouseDown)); + fxOn(this._teardownFns, this.canvas, 'mousedown', onMouseDown); const onMouseMove = (e) => { if (this.isDragging) this.handleDrag(e); }; - window.addEventListener('mousemove', onMouseMove); - this._teardownFns.push(() => window.removeEventListener('mousemove', onMouseMove)); + fxOn(this._teardownFns, window, 'mousemove', onMouseMove); const onMouseUp = () => { this.isDragging = false; }; - window.addEventListener('mouseup', onMouseUp); - this._teardownFns.push(() => window.removeEventListener('mouseup', onMouseUp)); + fxOn(this._teardownFns, window, 'mouseup', onMouseUp); const onWheel = (e) => { e.preventDefault(); @@ -188,8 +184,7 @@ class MinimapRenderer { this.viewer.renderAll(); }; - this.canvas.addEventListener('wheel', onWheel, { passive: false }); - this._teardownFns.push(() => this.canvas.removeEventListener('wheel', onWheel)); + fxOn(this._teardownFns, this.canvas, 'wheel', onWheel, { passive: false }); } handleDrag(e) { @@ -282,11 +277,6 @@ class MinimapRenderer { } destroy() { - while (this._teardownFns.length > 0) { - const off = this._teardownFns.pop(); - try { - off(); - } catch (_) {} - } + fxOffAll(this._teardownFns); } } diff --git a/backends/qualcomm/utils/fx_viewer/templates/themes.js b/backends/qualcomm/utils/fx_viewer/templates/themes.js index 02ad2f5c30a..2c70404e47c 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/themes.js +++ b/backends/qualcomm/utils/fx_viewer/templates/themes.js @@ -1,3 +1,26 @@ +function fxOn(teardownFns, target, eventName, handler, options) { + if (!target || !target.addEventListener || !target.removeEventListener) return; + target.addEventListener(eventName, handler, options); + teardownFns.push(() => target.removeEventListener(eventName, handler, options)); +} + +function fxOffAll(teardownFns) { + while (teardownFns.length > 0) { + const off = teardownFns.pop(); + try { + off(); + } catch (_) {} + } +} + +const FX_COMPARE_COMPACT_LAYOUT_PATCH = Object.freeze({ + panels: Object.freeze({ + sidebar: Object.freeze({ visible: false }), + minimap: Object.freeze({ visible: false }), + info: Object.freeze({ visible: false }), + }), +}); + const THEMES = { 'light': { bg: '#ffffff', diff --git a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js index f5bcc640bb1..8139f8c7bcf 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js +++ b/backends/qualcomm/utils/fx_viewer/templates/ui_manager.js @@ -79,6 +79,15 @@ class UIManager { this.buildUI(); } + _createTaskbarButton({ html, title, onClick, className = 'fx-button' }) { + const btn = document.createElement('button'); + btn.className = className; + btn.innerHTML = html; + if (title) btn.title = title; + if (typeof onClick === 'function') btn.onclick = onClick; + return btn; + } + buildUI() { this.taskbar = document.createElement('div'); this.taskbar.className = 'fx-taskbar'; @@ -116,9 +125,9 @@ class UIManager { this.layersContainer.style.position = 'relative'; this.layersContainer.style.marginLeft = '10px'; - this.btnLayers = document.createElement('button'); - this.btnLayers.className = 'fx-button'; - this.btnLayers.innerHTML = '📚 Layers'; + this.btnLayers = this._createTaskbarButton({ + html: '📚 Layers', + }); this.layersMenu = document.createElement('div'); this.layersMenu.className = 'fx-layers-menu'; @@ -134,56 +143,56 @@ class UIManager { } if (this.controls.highlightButton) { - this.btnHighlight = document.createElement('button'); - this.btnHighlight.className = 'fx-button active'; - this.btnHighlight.innerHTML = '🔗'; - this.btnHighlight.title = 'Toggle Highlight Ancestors/Descendants'; - this.btnHighlight.onclick = () => { - this.controller.state.highlightAncestors = !this.controller.state.highlightAncestors; - this.btnHighlight.classList.toggle('active', this.controller.state.highlightAncestors); - this.controller.setState({}); - }; + this.btnHighlight = this._createTaskbarButton({ + html: '🔗', + title: 'Toggle Highlight Ancestors/Descendants', + className: 'fx-button active', + onClick: () => { + this.controller.state.highlightAncestors = !this.controller.state.highlightAncestors; + this.btnHighlight.classList.toggle('active', this.controller.state.highlightAncestors); + this.controller.setState({}); + }, + }); this.taskbar.appendChild(this.btnHighlight); } if (this.controls.zoomButtons) { - this.btnZoomFit = document.createElement('button'); - this.btnZoomFit.className = 'fx-button'; - this.btnZoomFit.innerHTML = '⛶'; - this.btnZoomFit.title = 'Zoom to Fit'; - this.btnZoomFit.onclick = () => this.controller.zoomToFit(); + this.btnZoomFit = this._createTaskbarButton({ + html: '⛶', + title: 'Zoom to Fit', + onClick: () => this.controller.zoomToFit(), + }); this.taskbar.appendChild(this.btnZoomFit); } if (this.controls.fullscreenButton) { - this.btnFullscreen = document.createElement('button'); - this.btnFullscreen.className = 'fx-button'; - this.btnFullscreen.innerHTML = '⛶'; - this.btnFullscreen.title = 'Enter Fullscreen'; - this.btnFullscreen.onclick = async () => { - if (document.fullscreenElement) { - await this.viewer.exitFullscreen(); - } else { - await this.viewer.enterFullscreen(); - } - this.syncFullscreenButton(); - }; + this.btnFullscreen = this._createTaskbarButton({ + html: '⛶', + title: 'Enter Fullscreen', + onClick: async () => { + if (document.fullscreenElement) { + await this.viewer.exitFullscreen(); + } else { + await this.viewer.enterFullscreen(); + } + this.syncFullscreenButton(); + }, + }); this.taskbar.appendChild(this.btnFullscreen); this._onFullscreenChange = () => this.syncFullscreenButton(); - document.addEventListener('fullscreenchange', this._onFullscreenChange); - this._teardownFns.push(() => document.removeEventListener('fullscreenchange', this._onFullscreenChange)); + fxOn(this._teardownFns, document, 'fullscreenchange', this._onFullscreenChange); } if (this.controls.clearButton) { - this.btnClear = document.createElement('button'); - this.btnClear.className = 'fx-button'; - this.btnClear.innerHTML = '✖'; - this.btnClear.title = 'Clear Selection'; - this.btnClear.onclick = () => { - if (this.searchInput) this.searchInput.value = ''; - this.controller.handleSearch(''); - this.controller.clearSelection(); - }; + this.btnClear = this._createTaskbarButton({ + html: '✖', + title: 'Clear Selection', + onClick: () => { + if (this.searchInput) this.searchInput.value = ''; + this.controller.handleSearch(''); + this.controller.clearSelection(); + }, + }); this.taskbar.appendChild(this.btnClear); } @@ -221,12 +230,12 @@ class UIManager { this.renderLegend(); if (this.searchInput) { - this.searchInput.addEventListener('input', (e) => { + fxOn(this._teardownFns, this.searchInput, 'input', (e) => { this.controller.handleSearch(e.target.value); this.searchMenu.style.display = e.target.value ? 'block' : 'none'; }); - this.searchInput.addEventListener('keydown', (e) => { + fxOn(this._teardownFns, this.searchInput, 'keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); this.controller.handleSearchNavigate(1); @@ -251,8 +260,7 @@ class UIManager { this.layersMenu.style.display = 'none'; } }; - document.addEventListener('click', this._onDocumentClick); - this._teardownFns.push(() => document.removeEventListener('click', this._onDocumentClick)); + fxOn(this._teardownFns, document, 'click', this._onDocumentClick); this.syncControlsFromState(); this.syncFullscreenButton(); @@ -660,12 +668,7 @@ class UIManager { } destroy() { - while (this._teardownFns.length > 0) { - const off = this._teardownFns.pop(); - try { - off(); - } catch (_) {} - } + fxOffAll(this._teardownFns); if (this.taskbar && this.taskbar.parentNode) this.taskbar.parentNode.removeChild(this.taskbar); if (this.legendOverlay && this.legendOverlay.parentNode) this.legendOverlay.parentNode.removeChild(this.legendOverlay); if (this.infoPanel && this.infoPanel.parentNode) this.infoPanel.parentNode.removeChild(this.infoPanel); From f9ecab37fe4744fffc27232bfd6afb17e9d580c8 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 20:12:00 +0800 Subject: [PATCH 12/65] fx_viewer/examples: add tutorial-style API learning ladder and mixed demo --- backends/qualcomm/utils/fx_viewer/README.md | 10 +- .../examples/FX_VIEWER_API_TESTCASES.md | 138 ++- .../fx_viewer/examples/PYTHON_API_TUTORIAL.md | 101 +++ .../fx_viewer/examples/harness_testcases.py | 823 +++++++++++++++--- 4 files changed, 931 insertions(+), 141 deletions(-) create mode 100644 backends/qualcomm/utils/fx_viewer/examples/PYTHON_API_TUTORIAL.md diff --git a/backends/qualcomm/utils/fx_viewer/README.md b/backends/qualcomm/utils/fx_viewer/README.md index 4e2b61194b4..9a3ccbea7e0 100644 --- a/backends/qualcomm/utils/fx_viewer/README.md +++ b/backends/qualcomm/utils/fx_viewer/README.md @@ -53,6 +53,9 @@ Main exporter methods: 3. `export_js(container_id)` 4. `export_html(path)` +Python tutorial: +1. `backends/qualcomm/utils/fx_viewer/examples/PYTHON_API_TUTORIAL.md` + ## JS API (Runtime) Construction: @@ -126,7 +129,7 @@ Files: 1. Generator: `backends/qualcomm/utils/fx_viewer/examples/generate_api_test_harness.py` 2. Template: `backends/qualcomm/utils/fx_viewer/examples/harness_template.html` 3. Testcases: `backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py` -4. Testcase reference: `backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md` +4. Tutorial testcase guide: `backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md` Generate harnesses: @@ -140,6 +143,11 @@ Generated outputs: 1. `fx_viewer_api_test_harness_portable.html` 2. `fx_viewer_api_test_harness_qualcomm.html` +Suggested learning order: +1. JS beginner ladder (`js_01` ... `js_08` in testcase guide). +2. Advanced combos (`adv_01` ... `adv_03`). +3. Final mixed demo (`js_99_combo_mixed`). + ## Testing Contract tests: diff --git a/backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md b/backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md index 70072e9434e..e2dd97ef13d 100644 --- a/backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md +++ b/backends/qualcomm/utils/fx_viewer/examples/FX_VIEWER_API_TESTCASES.md @@ -1,6 +1,7 @@ -# FX Viewer API Harness Testcases +# FX Viewer API Harness: Tutorial Testcases -This document tracks testcase intent and API coverage for the unified harness. +This document is a learning guide for the unified harness. +Read top-to-bottom and run cases in order. ## Harness Outputs @@ -10,28 +11,123 @@ This document tracks testcase intent and API coverage for the unified harness. Portable harness requires no Qualcomm SDK. Qualcomm harness requires QAIRT/QNN environment. -## Testcases +## Learning Order -1. `topology_split` -- Goal: baseline split viewer using structural extensions. -- APIs: `create`, `init`, `setLayers`, `setColorBy`. +### Level 1: API Fundamentals -2. `headless_slots` -- Goal: host-controlled layout with external slots and dynamic recoloring. -- APIs: `create` with `mount.slots`, `patchLayerNodes`, `setColorBy`. +1. `js_01_create_init_destroy` +- Purpose: learn viewer lifecycle. +- Target APIs: `FXGraphViewer.create`, `init`, `destroy`, `getState`. +- What to try: +1. Click `Create + Init`. +2. Click `Destroy`. +3. Repeat and observe state panel. -3. `accuracy_dynamic` -- Goal: real per-layer accuracy debug controls (threshold/theme/focus). -- APIs: `setTheme`, `selectNode`, `patchLayerNodes`, `setColorBy`. +2. `js_02_state_theme` +- Purpose: understand state-driven controls. +- Target APIs: `getState`, `setState`, `setTheme`. +- What to try: +1. Switch light/dark theme. +2. Toggle highlight mode. +3. Watch `getState()` snapshot update. -4. `fullscreen_toolbar` -- Goal: demonstrate fullscreen integration in UI + direct API calls. -- APIs: `layout.fullscreen.button`, `enterFullscreen`, `exitFullscreen`. +3. `js_03_selection_camera` +- Purpose: learn navigation semantics. +- Target APIs: `selectNode`, `animateToNode`, `panToNode`, `zoomToFit`, `clearSelection`. +- What to try: +1. Use each button and compare motion behavior. +2. Confirm clear-selection resets visual focus. -5. `compare_sync` -- Goal: compare orchestration with sync toggles. -- APIs: `FXGraphCompare.create`, `setSync`, `setCompact`. +4. `js_04_layers_colorby` +- Purpose: separate "active layers" from "color source". +- Target APIs: `setLayers`, `setColorBy`. +- What to try: +1. Disable one layer and keep colorBy on the other. +2. Set colorBy to `base` and compare legend. -6. `qualcomm_metadata` (Qualcomm harness only) -- Goal: expose Qualcomm PTQ metadata next to real graph payload. -- APIs: `create`, metadata-driven host composition. +5. `js_05_runtime_mutation` +- Purpose: mutate overlays at runtime. +- Target APIs: `upsertLayer`, `patchLayerNodes`, `setLayerLabel`, `setColorRule`, `removeLayer`. +- What to try: +1. Move threshold slider. +2. Rename layer. +3. Apply color rule function. +4. Remove layer and revert to base. + +6. `js_06_layout_slots` +- Purpose: embed UI components in external host divs. +- Target APIs: `mount.slots`, `setLayout`, `setUIVisibility`. +- What to try: +1. Toggle info/minimap visibility. +2. Hide/show toolbar chrome. +3. Inspect slot ownership behavior. + +7. `js_07_events` +- Purpose: subscribe to viewer events from host code. +- Target APIs: `on`, `off` with `statechange`, `selectionchange`, `themechange`, `layoutchange`. +- What to try: +1. Trigger events with buttons. +2. Click `Unsubscribe Events`. +3. Confirm log no longer updates. + +8. `js_08_compare_basics` +- Purpose: first compare orchestration flow. +- Target APIs: `FXGraphCompare.create`, `setSync`, `setCompact`, `setColumns`. +- What to try: +1. Toggle selection/theme sync. +2. Change compact mode. +3. Change compare columns. + +### Level 2: Interesting Combinations + +9. `adv_01_accuracy_dynamic` +- Purpose: real per-layer accuracy workflow with host controls. +- Target APIs: `setTheme`, `patchLayerNodes`, `setColorBy`, `selectNode`. +- What to try: +1. Adjust severity percentile. +2. Focus highest-severity node. + +10. `adv_02_headless_slots_slider` +- Purpose: host-owned layout + slot embedding + dynamic recolor. +- Target APIs: `mount.slots`, `patchLayerNodes`, `setColorBy`. +- What to try: +1. Drag threshold slider. +2. Check info/minimap/legend in external panes. + +11. `adv_03_fullscreen_toolbar` +- Purpose: fullscreen via both toolbar and direct API. +- Target APIs: `layout.fullscreen.button`, `enterFullscreen`, `exitFullscreen`. +- What to try: +1. Enter/exit fullscreen from side buttons. +2. Use taskbar fullscreen toggle too. + +### Level 3: Current Mixed Demo + +12. `js_99_combo_mixed` +- Purpose: demonstrate a realistic mixed usage pattern. +- Target APIs: compare sync, runtime mutation, event subscriptions, theme control, camera APIs. +- What to try: +1. Toggle compare sync flags. +2. Move threshold slider. +3. Focus worst node. +4. Run scripted sequence. + +### Qualcomm-only + +13. `qualcomm_metadata` +- Purpose: inspect Qualcomm PTQ metadata beside rendered graph. +- Target APIs: `create` plus host metadata composition. + +## How to Learn Effectively + +1. Run one testcase at a time. +2. Edit JS pane in small changes and rerun. +3. Keep the "Target APIs" list in view while editing. +4. Move to the next level only after you can explain current behavior. + +## Common Mistakes + +1. Using `setColorBy(layer)` when that layer is not active. +2. Forgetting to call `init()` after `create()`. +3. Patching a layer that has not been added via `upsertLayer`. +4. Assuming compare sync covers all dimensions when only selection is enabled. diff --git a/backends/qualcomm/utils/fx_viewer/examples/PYTHON_API_TUTORIAL.md b/backends/qualcomm/utils/fx_viewer/examples/PYTHON_API_TUTORIAL.md new file mode 100644 index 00000000000..c10b75d3342 --- /dev/null +++ b/backends/qualcomm/utils/fx_viewer/examples/PYTHON_API_TUTORIAL.md @@ -0,0 +1,101 @@ +# fx_viewer Python API Tutorial + +This tutorial is intentionally practical and maps directly to harness usage. + +## 1) Minimal Export + +```python +import torch +from executorch.backends.qualcomm.utils.fx_viewer import FXGraphExporter + +model = torch.nn.Sequential(torch.nn.Linear(16, 16), torch.nn.ReLU()).eval() +sample = (torch.randn(1, 16),) + +ep = torch.export.export(model, sample, strict=False) +exporter = FXGraphExporter(ep.graph_module) +exporter.export_html("minimal_graph.html") +``` + +What you learn: +1. Create exporter from `graph_module`. +2. Generate standalone HTML quickly. + +## 2) Add One Extension Layer + +```python +from executorch.backends.qualcomm.utils.fx_viewer import GraphExtension + +ext = GraphExtension(id="backend", name="Backend") + +payload = exporter.generate_json_payload() +for node in payload["base"]["nodes"]: + # Example: fake backend assignment + ext.add_node_data(node["id"], {"backend": "cpu"}) + +ext.set_label_formatter(lambda d: [f"backend={d.get('backend', 'unknown')}"]) +exporter.add_extension(ext) +exporter.export_html("graph_with_backend_layer.html") +``` + +What you learn: +1. `add_node_data(node_id, data)` is the core extension contract. +2. Formatter input is exactly stored extension data. + +## 3) Add Color Rules + +### Categorical + +```python +from executorch.backends.qualcomm.utils.fx_viewer import CategoricalColorRule + +ext.set_color_rule(CategoricalColorRule(attribute="backend")) +``` + +Use when values are discrete labels. + +### Numeric + +```python +from executorch.backends.qualcomm.utils.fx_viewer import NumericColorRule + +metric_ext = GraphExtension(id="latency", name="Latency") +metric_ext.add_node_data("node_a", {"latency_ms": 1.2}) +metric_ext.set_color_rule(NumericColorRule(attribute="latency_ms", cmap="viridis")) +``` + +Use when values are continuous metrics. + +## 4) Export Modes + +```python +payload = exporter.generate_json_payload() # in-memory dict +exporter.export_json("graph_payload.json") +js_snippet = exporter.export_js("graph-host") +exporter.export_html("graph_standalone.html") +``` + +Use cases: +1. `export_html`: easiest for local inspection. +2. `export_json` + JS runtime: best for custom host applications. +3. `export_js`: quick embed in existing HTML. + +## 5) Connect Python Output to JS Harness Thinking + +If Python emits: +1. `extensions["per_layer_accuracy"]` +2. `extensions["topological_order"]` + +Then JS harness can immediately use: +1. `viewer.setLayers(["per_layer_accuracy", "topological_order"])` +2. `viewer.setColorBy("per_layer_accuracy")` +3. `viewer.patchLayerNodes("per_layer_accuracy", patchByNodeId)` + +This is the core Python/JS contract boundary. + +## 6) Recommended Practice Path + +1. Start with `minimal_graph.html`. +2. Add one extension with one field. +3. Add categorical color. +4. Add numeric metric layer. +5. Validate behavior in JS harness beginner cases (`js_04`, `js_05`). diff --git a/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py b/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py index 5a7f6dfea62..084ddf25538 100644 --- a/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py +++ b/backends/qualcomm/utils/fx_viewer/examples/harness_testcases.py @@ -1,9 +1,7 @@ """Testcase catalog for the unified fx_viewer API harness. -This file keeps testcase definitions separate from payload generation so we can: -1) Reuse the same UI harness template. -2) Reuse the same testcase list across portable and Qualcomm profiles. -3) Keep JS snippets educational and easy to evolve. +This file intentionally orders cases from simple to advanced so the harness doubles +as a tutorial. """ from __future__ import annotations @@ -14,87 +12,377 @@ def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: cases: list[dict[str, Any]] = [ { - "id": "topology_split", - "title": "Topology + Type Layers (Split)", - "description": "Baseline split layout. Demonstrates setLayers/setColorBy + legend updates.", + "id": "js_01_create_init_destroy", + "title": "JS 01: Create / Init / Destroy", + "description": "Smallest viewer lifecycle example.", "html": """ -
+
+
+ + + Target APIs: create, init, destroy, getState +
+
+
+

+  
+
+""".strip(), + "js": """ +let viewer = null; + +function renderState() { + const stateEl = document.getElementById('c1_state'); + if (!viewer) { + stateEl.textContent = 'viewer = null'; + return; + } + const s = viewer.getState(); + stateEl.textContent = JSON.stringify({ + theme: s.theme, + colorBy: s.colorBy, + selectedNodeId: s.selectedNodeId, + activeExtensions: s.activeExtensions, + }, null, 2); +} + +function createViewer() { + if (viewer) viewer.destroy(); + viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#c1_view' }, + layout: { preset: 'split' }, + }); + viewer.init(); + renderState(); + api.log('Created + initialized viewer'); +} + +function destroyViewer() { + if (!viewer) return; + viewer.destroy(); + viewer = null; + renderState(); + api.log('Destroyed viewer'); +} + +document.getElementById('c1_create').addEventListener('click', createViewer); +document.getElementById('c1_destroy').addEventListener('click', destroyViewer); + +createViewer(); +api.setCleanup(() => destroyViewer()); +""".strip(), + }, + { + "id": "js_02_state_theme", + "title": "JS 02: State + Theme", + "description": "Learn getState/setState/setTheme with visible state snapshot.", + "html": """ +
+
+ + + Target APIs: getState, setState, setTheme +
+
+
+

+  
+
""".strip(), "js": """ -// Educational: create a standard split viewer and activate two extension layers. const viewer = FXGraphViewer.create({ payload: api.payloads.structural, - mount: { root: '#case_view' }, + mount: { root: '#c2_view' }, layout: { preset: 'split' }, state: { theme: 'light' }, }); viewer.init(); +api.registerViewer(viewer); + +const stateEl = document.getElementById('c2_state'); +const themeSel = document.getElementById('c2_theme'); -// Show both structural layers, then color by topological order. -viewer.setLayers(['color_by_type', 'topological_order']); -viewer.setColorBy('topological_order'); +themeSel.addEventListener('change', () => { + viewer.setTheme(themeSel.value); + renderState(); +}); + +document.getElementById('c2_toggle_highlight').addEventListener('click', () => { + const s = viewer.getState(); + viewer.setState({ highlightAncestors: !s.highlightAncestors }); + renderState(); +}); + +function renderState() { + const s = viewer.getState(); + stateEl.textContent = JSON.stringify({ + theme: s.theme, + highlightAncestors: s.highlightAncestors, + colorBy: s.colorBy, + activeExtensions: s.activeExtensions, + camera: s.camera, + }, null, 2); +} +renderState(); +api.log('Use theme dropdown and highlight toggle, then inspect getState output.'); +""".strip(), + }, + { + "id": "js_03_selection_camera", + "title": "JS 03: Selection + Camera", + "description": "Control navigation APIs explicitly from custom host buttons.", + "html": """ +
+
+ + + + + + Target APIs: selectNode, animateToNode, panToNode, zoomToFit, clearSelection +
+
+
+""".strip(), + "js": """ +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#c3_view' }, + layout: { preset: 'split' }, +}); +viewer.init(); api.registerViewer(viewer); -api.log('Loaded structural payload with color_by_type + topological_order'); + +const ids = viewer.store.baseData.nodes.map((n) => n.id); +const firstId = ids[0]; +const midId = ids[Math.floor(ids.length / 2)]; +const lastId = ids[ids.length - 1]; + +document.getElementById('c3_select_first').addEventListener('click', () => { + viewer.selectNode(firstId, { center: true }); +}); + +document.getElementById('c3_select_mid').addEventListener('click', () => { + viewer.selectNode(midId, { animate: true, center: true }); +}); + +document.getElementById('c3_pan_last').addEventListener('click', () => { + viewer.panToNode(lastId); +}); + +document.getElementById('c3_zoom_fit').addEventListener('click', () => viewer.zoomToFit()); +document.getElementById('c3_clear').addEventListener('click', () => viewer.clearSelection()); + +api.log('Use buttons to see how camera + selection APIs differ.'); """.strip(), }, { - "id": "headless_slots", - "title": "Headless Slots + Dynamic Slider", - "description": "Demonstrates mount.slots ownership + patchLayerNodes for dynamic recoloring.", + "id": "js_04_layers_colorby", + "title": "JS 04: Layers + ColorBy", + "description": "Learn extension activation and color source switching.", "html": """ -
-
-
-
-
Custom Controls
- -
-
-
-
-
+
+
+
Layer Controls
+ + +
+ +
+
+
+""".strip(), + "js": """ +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#c4_view' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['color_by_type', 'topological_order'], colorBy: 'topological_order' }, +}); +viewer.init(); +api.registerViewer(viewer); + +const layerType = document.getElementById('c4_layer_type'); +const layerTopo = document.getElementById('c4_layer_topo'); +const colorBy = document.getElementById('c4_colorby'); + +function applyLayers() { + const layers = []; + if (layerType.checked) layers.push('color_by_type'); + if (layerTopo.checked) layers.push('topological_order'); + viewer.setLayers(layers); +} + +layerType.addEventListener('change', applyLayers); +layerTopo.addEventListener('change', applyLayers); +colorBy.addEventListener('change', () => viewer.setColorBy(colorBy.value)); + +api.log('Toggle layers and colorBy to observe legend/canvas updates.'); +""".strip(), + }, + { + "id": "js_05_runtime_mutation", + "title": "JS 05: Runtime Layer Mutation", + "description": "Create, patch, recolor, relabel, and remove a dynamic layer at runtime.", + "html": """ +
+
+
Runtime Mutation Controls
+ +
+
+ + + +
+
+
+""".strip(), + "js": """ +const layerId = 'runtime_score'; +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#c5_view' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['color_by_type', 'topological_order'], colorBy: 'base' }, +}); +viewer.init(); +api.registerViewer(viewer); + +const allNodes = viewer.store.baseData.nodes.slice(0, 140); +const runtimeNodes = {}; +allNodes.forEach((n, idx) => { + runtimeNodes[n.id] = { + info: { runtime_score: idx / Math.max(1, allNodes.length - 1) }, + label_append: [`r=${(idx / Math.max(1, allNodes.length - 1)).toFixed(2)}`], + fill_color: '#93c5fd', + }; +}); + +viewer.upsertLayer(layerId, { + name: 'Runtime Score', + legend: [ + { label: 'low', color: '#93c5fd' }, + { label: 'high', color: '#b91c1c' }, + ], + nodes: runtimeNodes, +}); +viewer.setLayers(['color_by_type', 'topological_order', layerId]); +viewer.setColorBy(layerId); + +const slider = document.getElementById('c5_threshold'); +const valueEl = document.getElementById('c5_threshold_value'); + +function applyThreshold() { + const t = Number(slider.value) / 100; + valueEl.textContent = `threshold=${t.toFixed(2)}`; + const patch = {}; + Object.entries(runtimeNodes).forEach(([nodeId, nodeData]) => { + const score = Number((nodeData.info && nodeData.info.runtime_score) || 0); + patch[nodeId] = { + fill_color: score >= t ? '#b91c1c' : '#93c5fd', + label_append: [`r=${score.toFixed(2)}`], + }; + }); + viewer.patchLayerNodes(layerId, patch); + viewer.setColorBy(layerId); +} + +slider.addEventListener('input', applyThreshold); +applyThreshold(); + +document.getElementById('c5_rename').addEventListener('click', () => { + viewer.setLayerLabel(layerId, 'Runtime Score (renamed)'); +}); + +document.getElementById('c5_rule').addEventListener('click', () => { + viewer.setColorRule(layerId, (nodeData) => { + const s = Number((nodeData.info && nodeData.info.runtime_score) || 0); + return s > 0.7 ? '#14532d' : '#fef08a'; + }); + viewer.setColorBy(layerId); +}); + +document.getElementById('c5_remove').addEventListener('click', () => { + viewer.removeLayer(layerId); + viewer.setColorBy('base'); +}); + +api.log('This case targets runtime mutation APIs end-to-end.'); +""".strip(), + }, + { + "id": "js_06_layout_slots", + "title": "JS 06: Layout + External Slots", + "description": "Mount viewer pieces into external host divs and control layout/UI visibility.", + "html": """ +
+
+ + + + Target APIs: mount.slots, setLayout, setUIVisibility +
+ +
+
+
+
+
-
-
-
+
+
+
""".strip(), "js": """ -// Educational: in headless mode we mount viewer shell to a hidden root, -// and dock renderers into external slots controlled by host HTML. +let showInfo = true; +let showMinimap = true; +let showChrome = true; + const viewer = FXGraphViewer.create({ payload: api.payloads.structural, mount: { - root: '#headless_mount', + root: '#c6_hidden_root', slots: { - canvas: '#slot_canvas', - info: '#slot_info', - minimap: '#slot_minimap', - legend: '#slot_legend', + canvas: '#c6_canvas', + toolbar: '#c6_toolbar', + info: '#c6_info', + minimap: '#c6_minimap', + legend: '#c6_legend', }, }, layout: { preset: 'headless', panels: { - minimap: { visible: true, height: 220, resizable: false }, info: { visible: true }, + minimap: { visible: true, height: 220, resizable: false }, legend: { visible: true }, }, }, ui: { controls: { - toolbar: false, - search: false, - layers: false, - colorBy: false, - theme: false, + toolbar: true, + search: true, + layers: true, + colorBy: true, + theme: true, legend: true, - zoomButtons: false, - clearButton: false, - highlightButton: false, + zoomButtons: true, + clearButton: true, + highlightButton: true, fullscreenButton: false, }, }, @@ -103,51 +391,167 @@ def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: viewer.init(); api.registerViewer(viewer); -// Educational: dynamic patch by node id + re-apply colorBy. -const slider = document.getElementById('topo_threshold'); -const valueEl = document.getElementById('topo_threshold_value'); -const nodes = viewer.store.extensions['topological_order'].nodes; -const maxTopo = Math.max(...Object.values(nodes).map(n => Number(n.info.topo_index || 0))); -slider.max = String(maxTopo); +document.getElementById('c6_toggle_info').addEventListener('click', () => { + showInfo = !showInfo; + viewer.setLayout({ panels: { info: { visible: showInfo } } }); +}); -function renderThreshold() { - const threshold = Number(slider.value); - valueEl.textContent = `threshold=${threshold} / ${maxTopo}`; - const patch = {}; - Object.entries(nodes).forEach(([nodeId, nodeData]) => { - const idx = Number(nodeData.info.topo_index || 0); - patch[nodeId] = { fill_color: idx >= threshold ? '#b91c1c' : '#93c5fd' }; +document.getElementById('c6_toggle_minimap').addEventListener('click', () => { + showMinimap = !showMinimap; + viewer.setLayout({ panels: { minimap: { visible: showMinimap } } }); +}); + +document.getElementById('c6_toggle_chrome').addEventListener('click', () => { + showChrome = !showChrome; + viewer.setUIVisibility({ + toolbar: showChrome, + search: showChrome, + layers: showChrome, + theme: showChrome, }); - viewer.patchLayerNodes('topological_order', patch); - viewer.setColorBy('topological_order'); +}); + +api.log('This case demonstrates host-owned slots and runtime layout toggles.'); +""".strip(), + }, + { + "id": "js_07_events", + "title": "JS 07: Events and Subscriptions", + "description": "Observe and unsubscribe viewer events from host code.", + "html": """ +
+
+ + + + + +
+
+
+

+  
+
+""".strip(), + "js": """ +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#c7_view' }, + layout: { preset: 'split' }, +}); +viewer.init(); +api.registerViewer(viewer); + +const logEl = document.getElementById('c7_log'); +const firstNode = viewer.store.baseData.nodes[0].id; +let minimapVisible = true; + +function log(line) { + logEl.textContent = `${line}\n${logEl.textContent}`.slice(0, 3000); } -slider.addEventListener('input', renderThreshold); -renderThreshold(); -api.log('Headless slot composition active. Move slider and inspect recoloring.'); +const offState = viewer.on('statechange', (evt) => log(`statechange source=${evt.source}`)); +const offSel = viewer.on('selectionchange', (evt) => log(`selection ${evt.prevSelection} -> ${evt.nextSelection}`)); +const offTheme = viewer.on('themechange', (evt) => log(`theme ${evt.prevTheme} -> ${evt.nextTheme}`)); +const offLayout = viewer.on('layoutchange', () => log('layoutchange')); + +document.getElementById('c7_theme').addEventListener('click', () => { + viewer.setTheme(viewer.getState().theme === 'light' ? 'dark' : 'light'); +}); +document.getElementById('c7_select').addEventListener('click', () => viewer.selectNode(firstNode, { animate: true, center: true })); +document.getElementById('c7_clear').addEventListener('click', () => viewer.clearSelection()); +document.getElementById('c7_layout').addEventListener('click', () => { + minimapVisible = !minimapVisible; + viewer.setLayout({ panels: { minimap: { visible: minimapVisible } } }); +}); +document.getElementById('c7_unsub').addEventListener('click', () => { + offState(); offSel(); offTheme(); offLayout(); + log('All event listeners unsubscribed'); +}); + +api.log('Trigger controls and inspect event stream in the right log panel.'); """.strip(), }, { - "id": "accuracy_dynamic", - "title": "Per-layer Accuracy Controls", - "description": "Real per-layer accuracy payload with dynamic threshold + theme sync.", + "id": "js_08_compare_basics", + "title": "JS 08: Compare Basics", + "description": "Minimal two-view compare orchestration with sync and layout controls.", "html": """ -
-
-
Accuracy Controls
+
+
+ + + + +
+
+
+
+
+
+""".strip(), + "js": """ +const left = FXGraphViewer.create({ + payload: api.payloads.accuracy_reference, + mount: { root: '#c8_left' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['color_by_type'], colorBy: 'color_by_type' }, +}); +left.init(); +api.registerViewer(left); + +const right = FXGraphViewer.create({ + payload: api.payloads.accuracy_candidate, + mount: { root: '#c8_right' }, + layout: { preset: 'split' }, + state: { activeExtensions: ['per_layer_accuracy'], colorBy: 'per_layer_accuracy' }, +}); +right.init(); +api.registerViewer(right); + +const compare = FXGraphCompare.create({ + viewers: [left, right], + layout: { columns: 2, compact: true, container: '#c8_grid' }, + sync: { selection: true, theme: false }, +}); +api.registerCompare(compare); + +document.getElementById('c8_sync_selection').addEventListener('change', (e) => { + compare.setSync({ selection: e.target.checked }); +}); + +document.getElementById('c8_sync_theme').addEventListener('change', (e) => { + compare.setSync({ theme: e.target.checked }); +}); + +document.getElementById('c8_compact').addEventListener('change', (e) => compare.setCompact(e.target.checked)); +document.getElementById('c8_cols').addEventListener('change', (e) => compare.setColumns(Number(e.target.value))); + +api.log('Try selecting nodes in either pane and toggling compare sync options.'); +""".strip(), + }, + { + "id": "adv_01_accuracy_dynamic", + "title": "ADV 01: Per-layer Accuracy Controls", + "description": "Interesting combo: real per-layer metrics + dynamic threshold + theme + focus.", + "html": """ +
+
+
Accuracy Controls
-
- -
- +
+ +
+
-
+
""".strip(), "js": """ -// Educational: this payload includes real per-layer accuracy metrics from capture outputs. const viewer = FXGraphViewer.create({ payload: api.payloads.accuracy_candidate, mount: { root: '#acc_view' }, @@ -164,7 +568,7 @@ def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: const extId = 'per_layer_accuracy'; const nodes = viewer.store.extensions[extId].nodes; const severities = Object.values(nodes) - .map(n => Number((n.info && n.info.severity_score) || 0)) + .map((n) => Number((n.info && n.info.severity_score) || 0)) .filter(Number.isFinite) .sort((a, b) => a - b); @@ -215,85 +619,266 @@ def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: """.strip(), }, { - "id": "fullscreen_toolbar", - "title": "Fullscreen Button API", - "description": "Taskbar fullscreen button (layout.fullscreen.button) + programmatic fullscreen API.", + "id": "adv_02_headless_slots_slider", + "title": "ADV 02: Headless Slots + Slider", + "description": "Interesting combo: custom host layout + external slots + dynamic recoloring.", "html": """ -
-
-
Fullscreen Controls
- - -

Taskbar also has a fullscreen toggle button in this case.

+
+ +
+
+
Custom Controls
+ +
+
+
+
+
+
+
+
+
+
-
""".strip(), "js": """ -// Educational: fullscreen button is enabled by layout.fullscreen.button. const viewer = FXGraphViewer.create({ payload: api.payloads.structural, - mount: { root: '#fs_view' }, + mount: { + root: '#adv2_headless_mount', + slots: { + canvas: '#adv2_slot_canvas', + info: '#adv2_slot_info', + minimap: '#adv2_slot_minimap', + legend: '#adv2_slot_legend', + }, + }, + layout: { + preset: 'headless', + panels: { + minimap: { visible: true, height: 220, resizable: false }, + info: { visible: true }, + legend: { visible: true }, + }, + }, + ui: { + controls: { + toolbar: false, + search: false, + layers: false, + colorBy: false, + theme: false, + legend: true, + zoomButtons: false, + clearButton: false, + highlightButton: false, + fullscreenButton: false, + }, + }, + state: { activeExtensions: ['topological_order'], colorBy: 'topological_order' }, +}); +viewer.init(); +api.registerViewer(viewer); + +const slider = document.getElementById('adv2_topo_threshold'); +const valueEl = document.getElementById('adv2_topo_threshold_value'); +const nodes = viewer.store.extensions['topological_order'].nodes; +const maxTopo = Math.max(...Object.values(nodes).map((n) => Number(n.info.topo_index || 0))); +slider.max = String(maxTopo); + +function renderThreshold() { + const threshold = Number(slider.value); + valueEl.textContent = `threshold=${threshold} / ${maxTopo}`; + const patch = {}; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const idx = Number(nodeData.info.topo_index || 0); + patch[nodeId] = { fill_color: idx >= threshold ? '#b91c1c' : '#93c5fd' }; + }); + viewer.patchLayerNodes('topological_order', patch); + viewer.setColorBy('topological_order'); +} + +slider.addEventListener('input', renderThreshold); +renderThreshold(); +api.log('Headless slot composition active. Move slider and inspect recoloring.'); +""".strip(), + }, + { + "id": "adv_03_fullscreen_toolbar", + "title": "ADV 03: Fullscreen + Toolbar API", + "description": "Interesting combo: fullscreen button in toolbar + direct fullscreen APIs.", + "html": """ +
+
+
Fullscreen Controls
+ + +

Taskbar also has a fullscreen toggle button in this case.

+
+
+
+""".strip(), + "js": """ +const viewer = FXGraphViewer.create({ + payload: api.payloads.structural, + mount: { root: '#adv3_view' }, layout: { preset: 'split', fullscreen: { enabled: true, button: true } }, state: { activeExtensions: ['color_by_type'], colorBy: 'color_by_type' }, }); viewer.init(); api.registerViewer(viewer); -document.getElementById('api_enter_fs').addEventListener('click', () => viewer.enterFullscreen()); -document.getElementById('api_exit_fs').addEventListener('click', () => viewer.exitFullscreen()); +document.getElementById('adv3_enter_fs').addEventListener('click', () => viewer.enterFullscreen()); +document.getElementById('adv3_exit_fs').addEventListener('click', () => viewer.exitFullscreen()); api.log('Use taskbar fullscreen button or side controls to validate API + UI integration.'); """.strip(), }, { - "id": "compare_sync", - "title": "Compare + Sync", - "description": "Native FXGraphCompare orchestration with sync and compact toggles.", + "id": "js_99_combo_mixed", + "title": "JS 99: Mixed Combo Demo", + "description": "Current mixed demo: compare + sync + runtime mutation + events + themed controls.", "html": """ -
- - -
-
-
-
+
+
+
Combo Controls
+ +
+ +
+
+
+
+ +
+ + +
+

+  
+
+
+
+
+
+
""".strip(), "js": """ const left = FXGraphViewer.create({ payload: api.payloads.accuracy_reference, - mount: { root: '#cmp_left' }, + mount: { root: '#c99_left' }, layout: { preset: 'split' }, + state: { activeExtensions: ['color_by_type'], colorBy: 'color_by_type', theme: 'light' }, }); left.init(); api.registerViewer(left); const right = FXGraphViewer.create({ payload: api.payloads.accuracy_candidate, - mount: { root: '#cmp_right' }, + mount: { root: '#c99_right' }, layout: { preset: 'split' }, - state: { activeExtensions: ['per_layer_accuracy'], colorBy: 'per_layer_accuracy' }, + state: { + activeExtensions: ['per_layer_accuracy', 'topological_order', 'color_by_type'], + colorBy: 'per_layer_accuracy', + theme: 'light', + }, }); right.init(); api.registerViewer(right); const compare = FXGraphCompare.create({ viewers: [left, right], - layout: { columns: 2, compact: true, container: '#cmp_grid' }, - sync: { selection: true }, + layout: { columns: 2, compact: true, container: '#c99_grid' }, + sync: { selection: true, theme: true }, }); api.registerCompare(compare); -document.getElementById('cmp_sync').addEventListener('change', (e) => { - compare.setSync({ selection: e.target.checked }); +const logEl = document.getElementById('c99_log'); +function log(msg) { + logEl.textContent = `${new Date().toLocaleTimeString()} ${msg}\n${logEl.textContent}`.slice(0, 4000); +} + +const offSel = right.on('selectionchange', (evt) => log(`selection ${evt.prevSelection} -> ${evt.nextSelection}`)); +const offTheme = right.on('themechange', (evt) => log(`theme ${evt.prevTheme} -> ${evt.nextTheme}`)); + +api.setCleanup(() => { + offSel(); + offTheme(); }); -document.getElementById('cmp_compact').addEventListener('change', (e) => { - compare.setCompact(e.target.checked); +const extId = 'per_layer_accuracy'; +const nodes = right.store.extensions[extId].nodes; +const severities = Object.values(nodes) + .map((n) => Number((n.info && n.info.severity_score) || 0)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + +function quantile(q) { + if (severities.length === 0) return 0; + const i = Math.min(severities.length - 1, Math.max(0, Math.floor(q * (severities.length - 1)))); + return severities[i]; +} + +function applyThreshold() { + const slider = document.getElementById('c99_threshold'); + const threshold = quantile(Number(slider.value) / 100); + document.getElementById('c99_threshold_text').textContent = + `percentile=${slider.value} threshold=${threshold.toExponential(3)}`; + const patch = {}; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const s = Number((nodeData.info && nodeData.info.severity_score) || 0); + patch[nodeId] = { + fill_color: s >= threshold ? '#991b1b' : '#fecaca', + label_append: [`sev=${s.toExponential(2)}`], + }; + }); + right.patchLayerNodes(extId, patch); + right.setColorBy(extId); +} + +function focusWorst() { + let worst = null; + let worstScore = -Infinity; + Object.entries(nodes).forEach(([nodeId, nodeData]) => { + const s = Number((nodeData.info && nodeData.info.severity_score) || 0); + if (s > worstScore) { + worstScore = s; + worst = nodeId; + } + }); + if (worst) { + right.selectNode(worst, { animate: true, center: true }); + log(`focus worst node=${worst} score=${worstScore.toExponential(3)}`); + } +} + +document.getElementById('c99_theme').addEventListener('change', (e) => { + left.setTheme(e.target.value); +}); +document.getElementById('c99_threshold').addEventListener('input', applyThreshold); +document.getElementById('c99_sync_sel').addEventListener('change', (e) => compare.setSync({ selection: e.target.checked })); +document.getElementById('c99_sync_theme').addEventListener('change', (e) => compare.setSync({ theme: e.target.checked })); +document.getElementById('c99_compact').addEventListener('change', (e) => compare.setCompact(e.target.checked)); +document.getElementById('c99_focus_worst').addEventListener('click', focusWorst); +document.getElementById('c99_sequence').addEventListener('click', () => { + log('scripted sequence start'); + left.setTheme('dark'); + applyThreshold(); + focusWorst(); + left.zoomToFit(); + right.zoomToFit(); + log('scripted sequence done'); }); -api.log('Select nodes in either pane to verify synced focus behavior.'); +applyThreshold(); +log('Mixed combo demo ready.'); +api.log('Final mixed demo: compare + sync + mutation + events + controls.'); """.strip(), }, ] @@ -302,15 +887,15 @@ def build_testcases(*, include_qualcomm: bool) -> list[dict[str, Any]]: cases.append( { "id": "qualcomm_metadata", - "title": "Qualcomm PTQ Metadata", + "title": "QUALCOMM: PTQ Metadata", "description": "Qualcomm-specific payload metadata from real QNN PTQ path.", "html": """ -
-
-

Qualcomm Metadata

-

+
+
+

Qualcomm Metadata

+

   
-
+
""".strip(), "js": """ From 537ecb3814a52439adc6b511f6177402f8d64ab2 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 22:09:56 +0800 Subject: [PATCH 13/65] observatory: scaffold RFC contracts and planning docs --- .../observatory/IMPLEMENTATION_PLAN.md | 76 +++++++ .../debugger/observatory/Questions.md | 18 ++ .../qualcomm/debugger/observatory/README.md | 107 ++++++++++ .../debugger/observatory/graph_hub.py | 60 ++++++ .../debugger/observatory/interfaces.py | 199 ++++++++++++++++++ .../qualcomm/debugger/observatory/utils.py | 138 ++++++++++++ 6 files changed, 598 insertions(+) create mode 100644 backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md create mode 100644 backends/qualcomm/debugger/observatory/Questions.md create mode 100644 backends/qualcomm/debugger/observatory/README.md create mode 100644 backends/qualcomm/debugger/observatory/graph_hub.py create mode 100644 backends/qualcomm/debugger/observatory/interfaces.py create mode 100644 backends/qualcomm/debugger/observatory/utils.py diff --git a/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md b/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000000..aae56d1eab2 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md @@ -0,0 +1,76 @@ +# Observatory GraphView Minimal Redesign Plan (Context Checkpoint) + +## Branch and Scope +- Working branch: `dev1/boyuc/observatory_graphview_minimal` +- Base context: includes prior `fx_viewer` API refinement commits. +- Final rebase target requested by user: `MLG/dev`. +- Non-goal for this branch: delete old `backends/qualcomm/debugger/debugging_utils`; keep old infra intact and introduce a new review-focused implementation under `backends/qualcomm/debugger/observatory`. + +## User-Approved Reuse Patterns +1. Reuse existing monkey-patch lifecycle pattern for auto collection. +2. Reuse `fx_viewer` runtime APIs for graph mount/compare. +3. Keep RFC class structure (explicitly requested) instead of custom simplified dispatch class hierarchy. +4. Keep serialization/safe-call helpers centralized. + +## Design Constraints From User +1. Preserve original observatory JS/HTML behavior as much as possible. +2. Split JS into separate template files for readability/review. +3. Use RFC contracts (`ViewBlock`, `ViewList`, `GraphView`, `GraphHub`, `GraphLens`) in the new infra. +4. Migrate only minimal lenses at first: metadata and stack trace. +5. No manual `observe()` insertion for ETRecord collection; use monkey-patching to auto insert collection points. +6. Add demo + tutorial/test plan, including swIN-style flow and compare support. + +## Source References Reviewed +- RFC: + - `backends/qualcomm/debugger/debugging_utils/RFC_OBSERVATORY_GRAPHVIEW_INTEGRATION.md` + - `backends/qualcomm/debugger/debugging_utils/FX_VIEWER_ACCURACY_INTEGRATION_PLAN.md` +- Existing observatory infra: + - `backends/qualcomm/debugger/debugging_utils/observatory.py` + - `backends/qualcomm/debugger/debugging_utils/interfaces.py` + - `backends/qualcomm/debugger/debugging_utils/html_template.py` + - `backends/qualcomm/debugger/debugging_utils/extensions/metadata.py` + - `backends/qualcomm/debugger/debugging_utils/extensions/stack_trace.py` + - `backends/qualcomm/debugger/debugging_utils/extensions/adb_execution.py` +- ETRecord integration points: + - `devtools/etrecord/_etrecord.py` +- fx_viewer embedding/runtime: + - `backends/qualcomm/utils/fx_viewer/README.md` + - `backends/qualcomm/utils/fx_viewer/exporter.py` + - `backends/qualcomm/utils/fx_viewer/templates/*.js` +- Existing examples for style and flow: + - `examples/qualcomm/oss_scripts/swin_transformer.py` + - `backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py` + +## Planned Commit Series +1. `observatory(core): scaffold RFC contracts and package layout` + - Add `observatory/interfaces.py`, `graph_hub.py`, package init files. +2. `observatory(lenses): add minimal metadata/stack_trace lenses` + - New lenses under `observatory/lenses/`. +3. `observatory(graph): add GraphLens and graph asset assembly` + - Graph extraction from `GraphModule`/`ExportedProgram` using `FXGraphExporter`. +4. `observatory(ui): split template JS into topic files and preserve current behavior` + - Move old single JS string logic to `observatory/templates/js/*.js`. + - Keep feature parity with old index/dashboard/compare flow. +5. `observatory(auto-collect): ETRecord monkey patch auto collection` + - Install/uninstall patch from context lifecycle. +6. `observatory(demo): add swin-style and GraphView compare demos` + - Add scripts in `observatory/examples/`. +7. `observatory(tests-docs): add tutorial-like test plan and usage docs` + - Add focused test cases and README notes. + +## Execution Checklist +- [ ] Build new observatory runtime and report export path. +- [ ] Verify record view and compare view behavior parity against old template baseline. +- [ ] Verify `GraphView` blocks mount `fx_viewer` correctly. +- [ ] Verify compare-mode graph sync toggle behavior. +- [ ] Verify ETRecord monkey-patch auto collects when context enabled. +- [ ] Run demo scripts with env setup: + - `source ~/executorch/.venv/bin/activate` + - `source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh` +- [ ] Capture commands/outcomes in docs. + +## Risk Controls +1. Keep old module untouched for rollback and behavior comparison. +2. Stage changes as small commits with runnable checkpoints. +3. Minimize logic churn in first UI pass; mostly mechanical split + explicit API wrappers. +4. Keep unresolved ambiguities in `Questions.md` and proceed with conservative defaults. diff --git a/backends/qualcomm/debugger/observatory/Questions.md b/backends/qualcomm/debugger/observatory/Questions.md new file mode 100644 index 00000000000..35877bc0a3c --- /dev/null +++ b/backends/qualcomm/debugger/observatory/Questions.md @@ -0,0 +1,18 @@ +# Questions / Assumptions Log + +This file records open design questions and assumptions without blocking implementation. + +## Open Questions +1. Should new observatory become default import path in examples immediately, or remain opt-in while old `debugging_utils` stays primary? +2. For GraphView compare, should `max_parallel > 2` be hard-rejected in v1 minimal or accepted and clipped to 2 in UI? +3. For ETRecord auto-collect naming, should method labels include source (`Exported`, `Edge`, `Extra`) or keep a flat naming convention for continuity with old reports? + +## Current Assumptions +1. New infra remains opt-in to avoid regression risk; old infra remains untouched. +2. Compare defaults use RFC compact profile and allow only 2 panes in first pass. +3. Auto-collected ETRecord records are prefixed to keep source traceable and review-friendly. +4. Minimal migration includes only metadata + stack trace lenses plus GraphLens built-in. + +## Follow-up (non-blocking) +- If reviewers request strict old naming compatibility, rename collect labels in one small follow-up commit. +- If reviewers want default adoption, switch imports in selected examples in a dedicated commit. diff --git a/backends/qualcomm/debugger/observatory/README.md b/backends/qualcomm/debugger/observatory/README.md new file mode 100644 index 00000000000..d3b012b8478 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/README.md @@ -0,0 +1,107 @@ +# Observatory (GraphView Minimal) + +This directory provides a new, review-focused Observatory implementation that follows the GraphView RFC contracts while keeping behavior close to the existing `debugging_utils` UI. + +## Goals +1. Keep implementation simple and easy to review. +2. Preserve original observatory report behavior where practical. +3. Make graph rendering first-class via `GraphView` and `GraphHub`. +4. Keep JS runtime code split into topic files under `templates/js`. +5. Support ETRecord auto-collection through monkey patching while context is enabled. + +## Main Files +1. `interfaces.py`: RFC-style contracts (`ViewBlock`, `ViewList`, `GraphView`, `Lens`, `Frontend`). +2. `observatory.py`: context lifecycle, collection, analysis, report payload assembly. +3. `graph_hub.py`: graph asset/layer registry for `graph_ref`-based reuse. +4. `auto_collect.py`: ETRecord monkey-patch install/uninstall. +5. `lenses/graph.py`: canonical base graph producer (`GraphLens`). +6. `lenses/metadata.py` and `lenses/stack_trace.py`: minimal migrated lenses. +7. `templates/js/*.js`: split UI runtime logic. +8. `templates/css/main.css`: UI styling baseline. + +## Quick Start + +```bash +source ~/executorch/.venv/bin/activate +source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh +export PYTHONPATH=~/ +``` + +```python +from executorch.backends.qualcomm.debugger.observatory import Observatory + +with Observatory.enable_context(): + # collect your graph artifacts + ... + +Observatory.export_html_report("/tmp/observatory_report.html") +``` + +## Block Contract Example + +```python +from executorch.backends.qualcomm.debugger.observatory.interfaces import ViewBlock, ViewList + +return ViewList( + blocks=[ + ViewBlock( + id="summary", + title="Summary", + type="table", + record={"data": {"a": 1}}, + compare={"mode": "auto"}, + ) + ] +) +``` + +## GraphView Example + +```python +from executorch.backends.qualcomm.debugger.observatory.interfaces import GraphView + +view = GraphView( + id="acc_graph", + title="Accuracy Graph", + graph_ref="record_0", + default_layers=["accuracy/error"], + default_color_by="accuracy/error", +) +block = view.as_block() +``` + +## ETRecord Auto-Collection + +`Observatory.enable_context()` installs temporary monkey patches on ETRecord methods: +1. `ETRecord.add_exported_program` +2. `ETRecord.add_edge_dialect_program` +3. `ETRecord.add_extra_export_modules` + +These calls automatically trigger Observatory collection while context is active. Patches are removed on outermost context exit. + +## Demos +1. `examples/demo_graphview_accuracy_compare.py` + - Graph compare with per-layer accuracy overlay. + - Supports `--model toy` and `--model swin`. +2. `examples/demo_etrecord_auto_collect.py` + - Demonstrates zero manual `collect()` for ETRecord paths. +3. `examples/generate_ui_test_harness.py` + - Generates an interactive HTML harness for JS/UI test cases. + +## JS Runtime Layout +1. `templates/js/00_state.js`: state bootstrap. +2. `templates/js/01_utils.js`: utilities + graph payload helpers. +3. `templates/js/02_layout.js`: shell and index rendering. +4. `templates/js/03_blocks.js`: block and compare rendering. +5. `templates/js/04_actions.js`: navigation/theme/selection actions. +6. `templates/js/05_bootstrap_api.js`: init + `window.ObservatoryAPI` + delegated actions. + +## Test Plan +1. Unit-level Python smoke: + - collect toy graph and export HTML. +2. ETRecord injection smoke: + - run `demo_etrecord_auto_collect.py` and verify records are captured. +3. GraphView compare smoke: + - run `demo_graphview_accuracy_compare.py` and compare records in report UI. +4. Interactive JS harness: + - run `generate_ui_test_harness.py` and verify block rendering and actions. diff --git a/backends/qualcomm/debugger/observatory/graph_hub.py b/backends/qualcomm/debugger/observatory/graph_hub.py new file mode 100644 index 00000000000..c65eda919ef --- /dev/null +++ b/backends/qualcomm/debugger/observatory/graph_hub.py @@ -0,0 +1,60 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +from typing import Any, Dict, Iterable, List + + +class GraphHub: + """Framework graph asset/layer registry for report assembly.""" + + def __init__(self) -> None: + self._graph_assets: Dict[str, Dict[str, Any]] = {} + self._graph_layers: Dict[str, Dict[str, Dict[str, Any]]] = {} + + def register_asset(self, graph_ref: str, base_payload: Dict[str, Any], meta: Dict[str, Any]) -> None: + if not graph_ref or not isinstance(base_payload, dict): + return + self._graph_assets[graph_ref] = { + "base": base_payload, + "meta": meta or {}, + } + + def add_layers(self, graph_ref: str, lens_name: str, layers: Iterable[Dict[str, Any]]) -> None: + if not graph_ref: + return + slot = self._graph_layers.setdefault(graph_ref, {}) + for layer in layers or []: + layer_id = str(layer.get("id") or "").strip() + if not layer_id: + continue + if "/" not in layer_id: + layer_id = f"{lens_name}/{layer_id}" + slot[layer_id] = { + "name": layer.get("name", layer_id), + "legend": layer.get("legend", []), + "nodes": layer.get("nodes", {}), + } + + def get_asset(self, graph_ref: str) -> Dict[str, Any]: + return self._graph_assets.get(graph_ref, {}) + + def build_payload(self) -> Dict[str, Any]: + return { + "graph_assets": self._graph_assets, + "graph_layers": self._graph_layers, + } + + @staticmethod + def build_viewer_payload(graph_assets: Dict[str, Any], graph_layers: Dict[str, Any], graph_ref: str) -> Dict[str, Any]: + asset = graph_assets.get(graph_ref, {}) + if not asset: + return {"base": {"legend": [], "nodes": [], "edges": []}, "extensions": {}} + return { + "base": asset.get("base", {"legend": [], "nodes": [], "edges": []}), + "extensions": graph_layers.get(graph_ref, {}), + } diff --git a/backends/qualcomm/debugger/observatory/interfaces.py b/backends/qualcomm/debugger/observatory/interfaces.py new file mode 100644 index 00000000000..6ff6ab42622 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/interfaces.py @@ -0,0 +1,199 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional, Union + + +Serializable = Union[Dict[str, Any], List[Any], str, int, float, bool, None] + + +@dataclass +class ViewBlock: + """Single renderable block for dashboard/record/compare views.""" + + id: str + title: str + type: Literal["table", "html", "custom", "graph"] + record: Dict[str, Any] = field(default_factory=dict) + compare: Dict[str, Any] = field(default_factory=dict) + order: int = 0 + collapsible: bool = True + + +@dataclass +class ViewList: + """Ordered list of blocks returned by frontends.""" + + blocks: List[ViewBlock] = field(default_factory=list) + + +@dataclass +class GraphView: + """Convenience model for GraphView blocks.""" + + id: str + title: str + graph_ref: str + default_layers: List[str] = field(default_factory=list) + default_color_by: Optional[str] = None + layer_scope: Union[str, List[str]] = "all" + viewer_options: Dict[str, Any] = field(default_factory=dict) + controls: Dict[str, Any] = field(default_factory=dict) + fullscreen: Dict[str, Any] = field(default_factory=dict) + compare: Dict[str, Any] = field( + default_factory=lambda: { + "mode": "auto", + "max_parallel": 2, + "sync_toggle": True, + "viewer_options_compare": { + "layout_mode": "compare_compact", + "sidebar_mode": "hidden", + "minimap_mode": "off", + "info_mode": "external", + }, + } + ) + order: int = 0 + collapsible: bool = True + + def as_block(self) -> ViewBlock: + """Convert to canonical ViewBlock.""" + record = { + "graph_ref": self.graph_ref, + "default_layers": self.default_layers, + "default_color_by": self.default_color_by, + "layer_scope": self.layer_scope, + "viewer_options": self.viewer_options, + "controls": self.controls, + "fullscreen": self.fullscreen, + } + return ViewBlock( + id=self.id, + title=self.title, + type="graph", + record=record, + compare=self.compare, + order=self.order, + collapsible=self.collapsible, + ) + + +class Frontend: + """Visualization strategy object returned by each lens.""" + + def resources(self) -> Dict[str, str]: + return {} + + def dashboard( + self, + start: Dict[str, Any], + end: Dict[str, Any], + analysis: Dict[str, Any], + records: List[Any], + ) -> Optional[ViewList]: + return None + + def record( + self, + digest: Any, + analysis: Dict[str, Any], + context: Dict[str, Any], + ) -> Optional[ViewList]: + return None + + def check_badges(self, digest: Any, analysis: Dict[str, Any]) -> List[Dict[str, str]]: + return [] + + def check_index_diffs( + self, + prev_digest: Any, + curr_digest: Any, + analysis: Dict[str, Any], + ) -> Dict[str, str]: + return {} + + +@dataclass +class ObservationContext: + """Context shared across lens runtime hooks.""" + + config: Dict[str, Any] + shared_state: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class RecordDigest: + """Persistent observation item.""" + + name: str + timestamp: float + data: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class SessionResult: + """Session start/end data from lens hooks.""" + + start_data: Dict[str, Serializable] = field(default_factory=dict) + end_data: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class AnalysisResult: + """Analysis output for dashboard and record rendering.""" + + global_data: Dict[str, Serializable] = field(default_factory=dict) + per_record_data: Dict[str, Serializable] = field(default_factory=dict) + + +class Lens: + """Protocol for Observatory lenses.""" + + @classmethod + def get_name(cls) -> str: + raise NotImplementedError() + + @classmethod + def setup(cls) -> None: + pass + + @classmethod + def on_session_start(cls, context: ObservationContext) -> Optional[Serializable]: + return None + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + return None + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Serializable: + return None + + @classmethod + def on_session_end(cls, context: ObservationContext) -> Optional[Serializable]: + return None + + @classmethod + def clear(cls) -> None: + pass + + @staticmethod + def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: + return AnalysisResult() + + @classmethod + def contribute_graph_layers( + cls, + digest: Any, + context: Dict[str, Any], + graph_context: Dict[str, Any], + ) -> List[Dict[str, Any]]: + return [] + + @staticmethod + def get_frontend_spec() -> Frontend: + return Frontend() diff --git a/backends/qualcomm/debugger/observatory/utils.py b/backends/qualcomm/debugger/observatory/utils.py new file mode 100644 index 00000000000..4433f20b397 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/utils.py @@ -0,0 +1,138 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import logging +import os +import subprocess +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class GitInfo: + """Git repository information for source links.""" + + remote_url: Optional[str] = None + branch: Optional[str] = None + commit_hash: Optional[str] = None + is_dirty: bool = False + github_link: Optional[str] = None + + +_cached_git_info: Optional[GitInfo] = None +_cached_repo_root: Optional[str] = None + + +def get_repo_root() -> Optional[str]: + """Return repository root, or None when unavailable.""" + + global _cached_repo_root + if _cached_repo_root is not None: + return _cached_repo_root + + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + _cached_repo_root = result.stdout.strip() + return _cached_repo_root + except Exception as exc: + logging.debug("[Observatory] Failed to detect repo root: %s", exc) + + return None + + +def get_git_info() -> GitInfo: + """Return git metadata used for stack trace source links.""" + + global _cached_git_info + if _cached_git_info is not None: + return _cached_git_info + + info = GitInfo() + + try: + upstream = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + capture_output=True, + text=True, + check=False, + ) + + remote_name = None + if upstream.returncode == 0: + upstream_name = upstream.stdout.strip() + if "/" in upstream_name: + remote_name, info.branch = upstream_name.split("/", 1) + + if info.branch is None: + local = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if local.returncode == 0: + info.branch = local.stdout.strip() + remote_name = "origin" + + if remote_name: + remote_url = subprocess.run( + ["git", "config", "--get", f"remote.{remote_name}.url"], + capture_output=True, + text=True, + check=False, + ) + if remote_url.returncode == 0: + info.remote_url = remote_url.stdout.strip() + + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=False, + ) + if commit.returncode == 0: + info.commit_hash = commit.stdout.strip() + + dirty = subprocess.run( + ["git", "status", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if dirty.returncode == 0: + info.is_dirty = bool(dirty.stdout.strip()) + + if info.remote_url and info.branch: + base = info.remote_url[:-4] if info.remote_url.endswith(".git") else info.remote_url + info.github_link = f"{base}/tree/{info.branch}" + except Exception as exc: + logging.debug("[Observatory] Failed to query git info: %s", exc) + + _cached_git_info = info + return info + + +def is_in_repo(filepath: str) -> bool: + """Check whether filepath is inside the current repository root.""" + + repo_root = get_repo_root() + if repo_root is None: + return False + + try: + abs_path = os.path.abspath(filepath) + abs_root = os.path.abspath(repo_root) + return abs_path.startswith(abs_root) + except Exception: + return False From 1c53d609b6adc6001a919253ca37e3a5428477e1 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 22:10:11 +0800 Subject: [PATCH 14/65] observatory: add core runtime, minimal lenses, and ETRecord auto-collect hook --- .../qualcomm/debugger/observatory/__init__.py | 9 + .../debugger/observatory/auto_collect.py | 104 ++++ .../debugger/observatory/lenses/__init__.py | 5 + .../debugger/observatory/lenses/graph.py | 91 ++++ .../debugger/observatory/lenses/metadata.py | 145 ++++++ .../observatory/lenses/stack_trace.py | 131 +++++ .../debugger/observatory/observatory.py | 454 ++++++++++++++++++ 7 files changed, 939 insertions(+) create mode 100644 backends/qualcomm/debugger/observatory/__init__.py create mode 100644 backends/qualcomm/debugger/observatory/auto_collect.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/__init__.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/graph.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/metadata.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/stack_trace.py create mode 100644 backends/qualcomm/debugger/observatory/observatory.py diff --git a/backends/qualcomm/debugger/observatory/__init__.py b/backends/qualcomm/debugger/observatory/__init__.py new file mode 100644 index 00000000000..344de06d00c --- /dev/null +++ b/backends/qualcomm/debugger/observatory/__init__.py @@ -0,0 +1,9 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from .observatory import Observatory + +__all__ = ["Observatory"] diff --git a/backends/qualcomm/debugger/observatory/auto_collect.py b/backends/qualcomm/debugger/observatory/auto_collect.py new file mode 100644 index 00000000000..7dc4dc9d844 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/auto_collect.py @@ -0,0 +1,104 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import logging +from typing import Any, Callable, Dict, Optional + + +class ETRecordAutoCollector: + """Monkey-patch ETRecord APIs to auto-collect graph observations.""" + + _installed: bool = False + _originals: Dict[str, Callable[..., Any]] = {} + + @classmethod + def install(cls, collect_fn: Callable[[str, Any], None]) -> None: + if cls._installed: + return + + try: + from executorch.devtools.etrecord._etrecord import ETRecord + except Exception as exc: + logging.warning("[Observatory] Failed to import ETRecord for auto-collect: %s", exc) + return + + def _safe_collect(name: str, artifact: Any) -> None: + try: + collect_fn(name, artifact) + except Exception as exc: + logging.debug("[Observatory] Auto-collect skipped (%s): %s", name, exc) + + def _wrap_add_exported_program(original): + def wrapped(self, exported_program): + result = original(self, exported_program) + if exported_program is None: + return result + if isinstance(exported_program, dict): + for method_name, program in exported_program.items(): + _safe_collect(f"ETRecord Exported/{method_name}", program) + else: + _safe_collect("ETRecord Exported/forward", exported_program) + return result + + return wrapped + + def _wrap_add_edge_dialect_program(original): + def wrapped(self, edge_dialect_program): + result = original(self, edge_dialect_program) + processed = getattr(self, "edge_dialect_program", None) + if isinstance(processed, dict): + for method_name, program in processed.items(): + _safe_collect(f"ETRecord Edge/{method_name}", program) + elif processed is not None: + _safe_collect("ETRecord Edge/forward", processed) + return result + + return wrapped + + def _wrap_add_extra_export_modules(original): + def wrapped(self, extra_recorded_export_modules): + result = original(self, extra_recorded_export_modules) + graph_map = getattr(self, "graph_map", {}) or {} + for module_name, program in graph_map.items(): + _safe_collect(f"ETRecord Extra/{module_name}", program) + return result + + return wrapped + + patches = { + "add_exported_program": _wrap_add_exported_program, + "add_edge_dialect_program": _wrap_add_edge_dialect_program, + "add_extra_export_modules": _wrap_add_extra_export_modules, + } + + for method_name, wrap_builder in patches.items(): + original = getattr(ETRecord, method_name, None) + if original is None: + continue + cls._originals[method_name] = original + setattr(ETRecord, method_name, wrap_builder(original)) + + cls._installed = True + + @classmethod + def uninstall(cls) -> None: + if not cls._installed: + return + + try: + from executorch.devtools.etrecord._etrecord import ETRecord + except Exception: + cls._originals.clear() + cls._installed = False + return + + for method_name, original in cls._originals.items(): + setattr(ETRecord, method_name, original) + + cls._originals.clear() + cls._installed = False diff --git a/backends/qualcomm/debugger/observatory/lenses/__init__.py b/backends/qualcomm/debugger/observatory/lenses/__init__.py new file mode 100644 index 00000000000..b5f86874fd4 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/__init__.py @@ -0,0 +1,5 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. diff --git a/backends/qualcomm/debugger/observatory/lenses/graph.py b/backends/qualcomm/debugger/observatory/lenses/graph.py new file mode 100644 index 00000000000..651ac541ae5 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/graph.py @@ -0,0 +1,91 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +from typing import Any, Dict, Optional + +import torch + +from executorch.backends.qualcomm.utils.fx_viewer import FXGraphExporter + +from ..interfaces import Frontend, GraphView, Lens, ObservationContext, ViewList + + +class GraphLens(Lens): + """Canonical producer of base fx_viewer graph payload per record.""" + + @classmethod + def get_name(cls) -> str: + return "graph" + + @classmethod + def _to_graph_module(cls, artifact: Any) -> Optional[torch.fx.GraphModule]: + if isinstance(artifact, torch.fx.GraphModule): + return artifact + + graph_module = getattr(artifact, "graph_module", None) + if isinstance(graph_module, torch.fx.GraphModule): + return graph_module + + try: + from torch.export import ExportedProgram + + if isinstance(artifact, ExportedProgram): + return artifact.graph_module + + exported_program = getattr(artifact, "exported_program", None) + if isinstance(exported_program, ExportedProgram): + return exported_program.graph_module + except Exception: + pass + + return None + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + graph_module = cls._to_graph_module(artifact) + if graph_module is None: + return None + + exporter = FXGraphExporter(graph_module) + payload = exporter.generate_json_payload() + + base = payload.get("base", {}) + record_name = str(context.shared_state.get("record_name") or "record") + + return { + "graph_ref": record_name, + "base": base, + "meta": { + "record_name": record_name, + "node_count": len(base.get("nodes", [])), + "edge_count": len(base.get("edges", [])), + }, + } + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return observation + + class GraphFrontend(Frontend): + def record(self, digest, analysis, context) -> Optional[ViewList]: + if not digest: + return None + + view = GraphView( + id="graph_main", + title="Graph", + graph_ref=str(digest.get("graph_ref", "")), + default_layers=[], + default_color_by="base", + order=10, + ) + return ViewList(blocks=[view.as_block()]) + + @staticmethod + def get_frontend_spec() -> Frontend: + return GraphLens.GraphFrontend() diff --git a/backends/qualcomm/debugger/observatory/lenses/metadata.py b/backends/qualcomm/debugger/observatory/lenses/metadata.py new file mode 100644 index 00000000000..ca80e2a6bd3 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/metadata.py @@ -0,0 +1,145 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import os +import platform +import sys +from datetime import datetime +from typing import Any, Dict, List, Optional + +import torch + +from ..interfaces import AnalysisResult, Frontend, Lens, ObservationContext, RecordDigest, ViewBlock, ViewList + + +class MetadataLens(Lens): + """Collects basic metadata about artifacts and runtime environment.""" + + @classmethod + def get_name(cls) -> str: + return "metadata" + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + artifact_type = str(type(artifact).__name__) + node_count: Any = "N/A" + + try: + from torch.export import ExportedProgram + + if isinstance(artifact, torch.fx.GraphModule): + artifact_type = "GM" + node_count = len(list(artifact.graph.nodes)) + elif isinstance(artifact, ExportedProgram): + artifact_type = "EP" + node_count = len(list(artifact.graph_module.graph.nodes)) + elif isinstance(artifact, torch.nn.Module): + artifact_type = "NN" + except Exception: + pass + + context.shared_state["artifact_type"] = artifact_type + return { + "artifact_type": artifact_type, + "node_count": node_count, + } + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return observation + + @classmethod + def on_session_start(cls, context: ObservationContext) -> Optional[Dict[str, Any]]: + return { + "command_line": " ".join(sys.orig_argv), + "python_version": sys.version.split("\n")[0], + "platform_system": platform.system(), + "platform_release": platform.release(), + "platform_machine": platform.machine(), + "working_directory": os.getcwd(), + "start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + } + + @staticmethod + def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: + diffs: Dict[str, Dict[str, int]] = {} + + for i in range(len(records) - 1): + def _count(rec: RecordDigest) -> int: + data = rec.data.get("metadata") + if not data: + return 0 + value = data.get("node_count") + return int(value) if isinstance(value, (int, float)) else 0 + + before = _count(records[i]) + after = _count(records[i + 1]) + diffs[records[i + 1].name] = {"node_diff": after - before} + + return AnalysisResult(per_record_data=diffs) + + class MetadataFrontend(Frontend): + def dashboard(self, start, end, analysis, records) -> Optional[ViewList]: + return ViewList( + blocks=[ + ViewBlock( + id="metadata_dashboard", + title="Session Metadata", + type="table", + record={"data": start or {}}, + compare={"mode": "disabled"}, + order=0, + ) + ] + ) + + def record(self, digest, analysis, context) -> Optional[ViewList]: + data = digest.copy() if isinstance(digest, dict) else {} + record_analysis = (analysis or {}).get("record") or {} + node_diff = record_analysis.get("node_diff", 0) + if node_diff: + data["nodes_change"] = f"{node_diff:+d}" + return ViewList( + blocks=[ + ViewBlock( + id="metadata_record", + title="Metadata", + type="table", + record={"data": data}, + compare={"mode": "auto"}, + order=0, + ) + ] + ) + + def check_index_diffs(self, prev_digest, curr_digest, analysis): + try: + before = int(prev_digest.get("node_count", 0)) + after = int(curr_digest.get("node_count", 0)) + diff = after - before + if diff: + return {"nodes": f"{diff:+d}"} + except Exception: + return {} + return {} + + def check_badges(self, digest, analysis): + badges = [] + if digest and "artifact_type" in digest: + badges.append( + { + "label": str(digest["artifact_type"]), + "class": "badge", + "title": "Artifact Type", + } + ) + return badges + + @staticmethod + def get_frontend_spec() -> Frontend: + return MetadataLens.MetadataFrontend() diff --git a/backends/qualcomm/debugger/observatory/lenses/stack_trace.py b/backends/qualcomm/debugger/observatory/lenses/stack_trace.py new file mode 100644 index 00000000000..98c7ce80615 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/stack_trace.py @@ -0,0 +1,131 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import inspect +import logging +import os +from typing import Any, Dict, List + +from ..interfaces import Frontend, Lens, ObservationContext, ViewBlock, ViewList +from ..utils import get_git_info, get_repo_root, is_in_repo + + +class StackTraceLens(Lens): + """Collects repository-local stack trace frames.""" + + @classmethod + def get_name(cls) -> str: + return "stack_trace" + + @classmethod + def _get_stack_trace(cls) -> List[Dict[str, Any]]: + repo_root = get_repo_root() + git_info = get_git_info() + + frames = [] + for frame_info in inspect.stack(): + if not is_in_repo(frame_info.filename): + continue + if "/observatory/observatory.py" in frame_info.filename.replace("\\", "/"): + continue + + github_link = None + if git_info.github_link and repo_root: + try: + rel_path = os.path.relpath(frame_info.filename, repo_root) + github_link = f"{git_info.github_link}/{rel_path}#L{frame_info.lineno}" + except Exception: + pass + + rel_path = frame_info.filename + if repo_root and frame_info.filename.startswith(repo_root): + rel_path = os.path.relpath(frame_info.filename, repo_root) + + frames.append( + { + "function": frame_info.function, + "filename": os.path.basename(rel_path), + "dir": os.path.dirname(rel_path), + "line": frame_info.lineno, + "context": frame_info.code_context[0].strip() if frame_info.code_context else None, + "link": github_link, + } + ) + + frames.reverse() + return frames + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + try: + return cls._get_stack_trace() + except Exception as exc: + logging.warning("[Observatory] Failed to collect stack trace: %s", exc) + return [] + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return observation + + class StackTraceFrontend(Frontend): + def record(self, digest, analysis, context): + if not digest: + return ViewList( + blocks=[ + ViewBlock( + id="stack_trace_record", + title="Stack Trace", + type="html", + record={"content": "
No stack trace available
"}, + compare={"mode": "auto"}, + order=40, + ) + ] + ) + + html = ["
"] + for frame in digest: + link_prefix = ( + f'' + if frame.get("link") + else "" + ) + link_suffix = "" if frame.get("link") else "" + snippet = "" + if frame.get("context"): + snippet = ( + "
" + f"{frame['context']}" + "
" + ) + + html.append( + "
" + f"
{frame['function']}
" + f"
{link_prefix}{frame['dir']}/{frame['filename']}:{frame['line']}{link_suffix}
" + f"{snippet}
" + ) + html.append("
") + + return ViewList( + blocks=[ + ViewBlock( + id="stack_trace_record", + title="Stack Trace", + type="html", + record={"content": "".join(html)}, + compare={"mode": "auto"}, + order=40, + ) + ] + ) + + @staticmethod + def get_frontend_spec() -> Frontend: + return StackTraceLens.StackTraceFrontend() diff --git a/backends/qualcomm/debugger/observatory/observatory.py b/backends/qualcomm/debugger/observatory/observatory.py new file mode 100644 index 00000000000..dd37edb2192 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/observatory.py @@ -0,0 +1,454 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import copy +import json +import logging +import os +import time +import traceback +from contextlib import contextmanager +from dataclasses import asdict +from datetime import datetime +from typing import Any, ContextManager, Dict, List, Optional, Set, Type + +from executorch.backends.qualcomm.utils.fx_viewer.exporter import FXGraphExporter + +from .auto_collect import ETRecordAutoCollector +from .graph_hub import GraphHub +from .interfaces import ( + AnalysisResult, + Frontend, + Lens, + ObservationContext, + RecordDigest, + SessionResult, + ViewBlock, + ViewList, +) + + +class Observatory: + """Global registry for collecting and rendering observability artifacts.""" + + _records: Dict[str, RecordDigest] = {} + _ignored_graphs: Set[str] = set() + _session_result: SessionResult = SessionResult() + _lens_registry: List[Type[Lens]] = [] + _lenses_initialized: bool = False + _config_stack: List[Dict[str, Any]] = [] + + @classmethod + def register_lens(cls, lens_cls: Type[Lens]) -> None: + if lens_cls in cls._lens_registry: + return + cls._lens_registry.append(lens_cls) + try: + lens_cls.setup() + except Exception as exc: + logging.error("[Observatory] Failed to setup lens %s: %s", lens_cls, exc) + + @classmethod + def _ensure_default_lenses(cls) -> None: + if cls._lenses_initialized: + return + + from .lenses.graph import GraphLens + from .lenses.metadata import MetadataLens + from .lenses.stack_trace import StackTraceLens + + cls.register_lens(GraphLens) + cls.register_lens(MetadataLens) + cls.register_lens(StackTraceLens) + cls._lenses_initialized = True + + @classmethod + def _merge_session_data(cls, target: Dict[str, Any], source: Optional[Dict[str, Any]]) -> None: + if source: + target.update(source) + + @classmethod + @contextmanager + def enable_context(cls, config: Optional[Dict[str, Any]] = None) -> ContextManager[None]: + """Enable observation context with optional nested overrides.""" + + cls._ensure_default_lenses() + + def merge_config_dict(base: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, Any]: + result = copy.copy(base) + result.update({k: copy.copy(v) for k, v in base.items() if isinstance(v, dict)}) + for key, value in new.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key].update(value) + else: + result[key] = value + return result + + parent_config = cls._config_stack[-1] if cls._config_stack else {} + context_config = merge_config_dict(parent_config, config or {}) + + is_outermost_start = len(cls._config_stack) == 0 + cls._config_stack.append(context_config) + hook_ctx = ObservationContext(config=context_config) + + if is_outermost_start: + ETRecordAutoCollector.install(cls.collect) + for lens in cls._lens_registry: + try: + data = lens.on_session_start(hook_ctx) + if data: + cls._session_result.start_data[lens.get_name()] = data + except Exception as exc: + logging.error("[Observatory] Lens %s failed on_session_start: %s", lens, exc) + + try: + yield + finally: + is_outermost_end = len(cls._config_stack) == 1 + + if is_outermost_end: + for lens in cls._lens_registry: + try: + data = lens.on_session_end(hook_ctx) + if data: + cls._session_result.end_data[lens.get_name()] = data + except Exception as exc: + logging.error("[Observatory] Lens %s failed on_session_end: %s", lens, exc) + ETRecordAutoCollector.uninstall() + + cls._config_stack.pop() + + @classmethod + def _get_current_context(cls) -> Optional[ObservationContext]: + if not cls._config_stack: + return None + return ObservationContext(config=cls._config_stack[-1]) + + @classmethod + def ignore_graphs(cls, names: List[str]) -> None: + for name in names: + cls._ignored_graphs.add(name) + if name in cls._records: + del cls._records[name] + + @classmethod + def collect(cls, name: str, artifact: Any) -> None: + if any(ignored in name for ignored in cls._ignored_graphs): + return + + if not cls._config_stack: + return + + active_config = cls._config_stack[-1] + ctx = ObservationContext(config=active_config) + ctx.shared_state["record_name"] = name + + record = RecordDigest(name=name, timestamp=datetime.now().timestamp()) + t_start = time.perf_counter() + + for lens in cls._lens_registry: + try: + lens_name = lens.get_name() + observation = lens.observe(artifact, ctx) + if observation is None: + continue + digest = lens.digest(observation, ctx) + if digest is not None: + record.data[lens_name] = digest + except Exception as exc: + logging.error("[Observatory] Lens %s failed collection for %s: %s", lens, name, exc) + + cls._records[name] = record + elapsed_ms = (time.perf_counter() - t_start) * 1000.0 + logging.info("[Observatory] Collected %s in %.1f ms", name, elapsed_ms) + + @classmethod + def list_collected(cls) -> List[str]: + return list(cls._records.keys()) + + @classmethod + def get(cls, name: str) -> Optional[RecordDigest]: + return cls._records.get(name) + + @classmethod + def clear(cls) -> None: + cls._records.clear() + cls._session_result = SessionResult() + ETRecordAutoCollector.uninstall() + + for lens in cls._lens_registry: + try: + lens.clear() + except Exception as exc: + logging.error("[Observatory] Lens %s failed clear: %s", lens, exc) + + @staticmethod + def _serialize_view_list(result: Any) -> Optional[Dict[str, Any]]: + if result is None: + return None + + if isinstance(result, ViewBlock): + result = ViewList(blocks=[result]) + + if not isinstance(result, ViewList): + raise TypeError(f"Frontend must return ViewList or ViewBlock, got {type(result)}") + + blocks = [] + for block in result.blocks: + if not isinstance(block, ViewBlock): + raise TypeError(f"ViewList.blocks must contain ViewBlock, got {type(block)}") + blocks.append(asdict(block)) + + return {"blocks": blocks} + + @classmethod + def _safe_frontend_call(cls, lens_name: str, method: Any, *args: Any, **kwargs: Any) -> Optional[Dict[str, Any]]: + try: + result = method(*args, **kwargs) + return cls._serialize_view_list(result) + except Exception as exc: + logging.error( + "[Observatory] Frontend %s.%s failed: %s\n%s", + lens_name, + getattr(method, "__name__", ""), + exc, + traceback.format_exc(), + ) + error_block = ViewBlock( + id="frontend_error", + title="Frontend Error", + type="html", + record={ + "content": ( + '
' + f"Error: {str(exc)}
" + ) + }, + compare={"mode": "disabled"}, + order=999, + ) + return {"blocks": [asdict(error_block)]} + + @classmethod + def _generate_report_payload( + cls, + records: List[RecordDigest], + session: SessionResult, + config: Dict[str, Any], + lens_registry: List[Type[Lens]], + ) -> Dict[str, Any]: + analysis_results: Dict[str, AnalysisResult] = { + lens.get_name(): lens.analyze(records, config) for lens in lens_registry + } + + resources: Dict[str, List[str]] = {"js": [], "css": []} + try: + resources["js"].append(FXGraphExporter._load_viewer_js_bundle()) + except Exception as exc: + logging.warning("[Observatory] Failed loading fx_viewer runtime bundle: %s", exc) + + for lens in lens_registry: + frontend = lens.get_frontend_spec() + res = frontend.resources() if isinstance(frontend, Frontend) else {} + if res.get("js"): + resources["js"].append(res["js"]) + if res.get("css"): + resources["css"].append(res["css"]) + + graph_hub = GraphHub() + serialized_records = [] + + for i, record in enumerate(records): + serialized = { + "name": record.name, + "timestamp": datetime.fromtimestamp(record.timestamp).strftime("%Y-%m-%d %H:%M:%S"), + "views": {}, + "badges": [], + "diff_index": {}, + "digests": record.data, + } + + for lens in lens_registry: + lens_name = lens.get_name() + digest = record.data.get(lens_name) + if digest is None: + continue + + analysis = analysis_results.get(lens_name, AnalysisResult()) + analysis_ctx = { + "global": analysis.global_data, + "record": analysis.per_record_data.get(record.name), + } + + if isinstance(digest, dict) and digest.get("graph_ref") and isinstance(digest.get("base"), dict): + graph_hub.register_asset( + str(digest["graph_ref"]), + digest["base"], + digest.get("meta", {}), + ) + + graph_ref = record.name + if isinstance(digest, dict) and digest.get("graph_ref"): + graph_ref = str(digest["graph_ref"]) + + try: + layers = lens.contribute_graph_layers( + digest, + { + "record_name": record.name, + "record_index": i, + }, + { + "graph_ref": graph_ref, + }, + ) + graph_hub.add_layers(graph_ref, lens_name, layers) + except Exception as exc: + logging.error("[Observatory] Lens %s graph layer contribution failed: %s", lens_name, exc) + + frontend = lens.get_frontend_spec() + try: + serialized["badges"].extend(frontend.check_badges(digest, analysis.global_data)) + except Exception as exc: + logging.error("[Observatory] check_badges failed for %s: %s", lens_name, exc) + + if i > 0: + prev_digest = records[i - 1].data.get(lens_name) + if prev_digest is not None: + try: + serialized["diff_index"].update( + frontend.check_index_diffs(prev_digest, digest, analysis.global_data) + ) + except Exception as exc: + logging.error("[Observatory] check_index_diffs failed for %s: %s", lens_name, exc) + + serialized_view = cls._safe_frontend_call( + lens_name, + frontend.record, + digest, + analysis_ctx, + {"index": i, "name": record.name}, + ) + if serialized_view: + serialized["views"][lens_name] = serialized_view + + serialized_records.append(serialized) + + dashboard_views = {} + for lens in lens_registry: + lens_name = lens.get_name() + frontend = lens.get_frontend_spec() + dashboard_view = cls._safe_frontend_call( + lens_name, + frontend.dashboard, + session.start_data.get(lens_name, {}), + session.end_data.get(lens_name, {}), + analysis_results.get(lens_name, AnalysisResult()).global_data, + records, + ) + if dashboard_view: + dashboard_views[lens_name] = dashboard_view + + graph_payload = graph_hub.build_payload() + + return { + "resources": resources, + "records": serialized_records, + "dashboard": dashboard_views, + "analysis_results": {k: asdict(v) for k, v in analysis_results.items()}, + "session": { + "start_data": session.start_data, + "end_data": session.end_data, + }, + "graph_assets": graph_payload["graph_assets"], + "graph_layers": graph_payload["graph_layers"], + } + + @classmethod + def export_html_report( + cls, + output_path: str, + title: str = "Observatory Report", + config: Optional[Dict[str, Any]] = None, + ) -> None: + if not cls._records: + logging.warning("[Observatory] No records collected, skipping HTML export") + return + + cls._ensure_default_lenses() + payload = cls._generate_report_payload( + list(cls._records.values()), + cls._session_result, + config or {}, + cls._lens_registry, + ) + payload["title"] = title + payload["generated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + from .html_template import get_html_template + + json_data = json.dumps(payload, default=str) + html_content = get_html_template(title, json_data) + + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + f.write(html_content) + + logging.info("[Observatory] Exported HTML report to %s", output_path) + + @classmethod + def export_json(cls, output_path: str) -> None: + if not cls._records: + logging.warning("[Observatory] No records collected, skipping JSON export") + return + + data = { + "records": [asdict(r) for r in cls._records.values()], + "session": asdict(cls._session_result), + } + + os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True) + with open(output_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + logging.info("[Observatory] Exported raw data to %s", output_path) + + @staticmethod + def generate_html_from_json( + json_path: str, + html_path: str, + title: str = "Observatory Report", + config: Optional[Dict[str, Any]] = None, + ) -> None: + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + + records = [RecordDigest(**r) for r in data["records"]] + session = SessionResult(**data["session"]) + + Observatory._ensure_default_lenses() + payload = Observatory._generate_report_payload( + records, + session, + config or {}, + Observatory._lens_registry, + ) + payload["title"] = title + payload["generated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + from .html_template import get_html_template + + json_data = json.dumps(payload, default=str) + html_content = get_html_template(title, json_data) + + os.makedirs(os.path.dirname(html_path) or ".", exist_ok=True) + with open(html_path, "w", encoding="utf-8") as f: + f.write(html_content) + + logging.info("[Observatory] Generated HTML report at %s from %s", html_path, json_path) From 4f46ad5132bbbb4a624806de2472fc81276d8d23 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 22:10:28 +0800 Subject: [PATCH 15/65] observatory(ui): split report runtime into topic JS template files --- .../debugger/observatory/html_template.py | 57 ++ .../debugger/observatory/template_loader.py | 44 ++ .../observatory/templates/css/main.css | 359 ++++++++++++ .../observatory/templates/js/00_state.js | 16 + .../observatory/templates/js/01_utils.js | 120 ++++ .../observatory/templates/js/02_layout.js | 150 +++++ .../observatory/templates/js/03_blocks.js | 521 ++++++++++++++++++ .../observatory/templates/js/04_actions.js | 103 ++++ .../templates/js/05_bootstrap_api.js | 143 +++++ 9 files changed, 1513 insertions(+) create mode 100644 backends/qualcomm/debugger/observatory/html_template.py create mode 100644 backends/qualcomm/debugger/observatory/template_loader.py create mode 100644 backends/qualcomm/debugger/observatory/templates/css/main.css create mode 100644 backends/qualcomm/debugger/observatory/templates/js/00_state.js create mode 100644 backends/qualcomm/debugger/observatory/templates/js/01_utils.js create mode 100644 backends/qualcomm/debugger/observatory/templates/js/02_layout.js create mode 100644 backends/qualcomm/debugger/observatory/templates/js/03_blocks.js create mode 100644 backends/qualcomm/debugger/observatory/templates/js/04_actions.js create mode 100644 backends/qualcomm/debugger/observatory/templates/js/05_bootstrap_api.js diff --git a/backends/qualcomm/debugger/observatory/html_template.py b/backends/qualcomm/debugger/observatory/html_template.py new file mode 100644 index 00000000000..743383096f1 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/html_template.py @@ -0,0 +1,57 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import html + +from .template_loader import load_css, load_js_chunks + + +def get_html_template(title: str, payload_json: str) -> str: + """Generate observatory HTML shell.""" + + css = load_css() + js_bundle = "\n".join(load_js_chunks()) + + return f""" + + + + + {html.escape(title)} + + + +
+ + + + + + + +""" diff --git a/backends/qualcomm/debugger/observatory/template_loader.py b/backends/qualcomm/debugger/observatory/template_loader.py new file mode 100644 index 00000000000..929f13a3a89 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/template_loader.py @@ -0,0 +1,44 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from __future__ import annotations + +import os +from typing import List + + +_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates") + + +def _read_file(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +def load_css() -> str: + """Load base observatory CSS.""" + + return _read_file(os.path.join(_TEMPLATE_DIR, "css", "main.css")) + + +def load_js_chunks() -> List[str]: + """Load ordered observatory JS runtime chunks.""" + + ordered = [ + "00_state.js", + "01_utils.js", + "02_layout.js", + "03_blocks.js", + "04_actions.js", + "05_bootstrap_api.js", + ] + + chunks: List[str] = [] + for filename in ordered: + path = os.path.join(_TEMPLATE_DIR, "js", filename) + chunks.append(f"\n// ---- {filename} ----\n") + chunks.append(_read_file(path)) + return chunks diff --git a/backends/qualcomm/debugger/observatory/templates/css/main.css b/backends/qualcomm/debugger/observatory/templates/css/main.css new file mode 100644 index 00000000000..f3edd8e9e2e --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/css/main.css @@ -0,0 +1,359 @@ + :root { + /* Light mode colors */ + --bg-primary: #f6f8fa; + --bg-secondary: #ffffff; + --bg-tertiary: #fafbfc; + --bg-code: #f4f4fa; + --text-primary: #24292e; + --text-secondary: #6a737d; + --text-inverse: #ffffff; + --border-color: #e1e4e8; + --header-bg: #24292e; + --link-color: #0366d6; + --link-hover: #0256c7; + --accent-color: #0366d6; + --success-color: #28a745; + --error-color: #d73a49; + --code-text: #d4d4d4; + --shadow: rgba(0,0,0,0.1); + --frame-border: #0366d6; + --diff-add-bg: #1e3a24; + --diff-add-color: #8cc696; + --diff-rem-bg: #4a2626; + --diff-rem-color: #e09690; + --diff-deep-add: #2e5c35; + --diff-deep-rem: #693333; + --success-bg: #dafbe1; + --error-bg: #ffebe9; + } + + [data-theme="dark"] { + /* Dark mode colors */ + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-code: #0d1117; + --text-primary: #c9d1d9; + --text-secondary: #8b949e; + --text-inverse: #c9d1d9; + --border-color: #30363d; + --header-bg: #161b22; + --link-color: #79c0ff; + --link-hover: #a5d6ff; + --accent-color: #58a6ff; + --success-color: #3fb950; + --error-color: #f85149; + --code-text: #c9d1d9; + --shadow: rgba(0,0,0,0.3); + --frame-border: #58a6ff; + --diff-add-bg: #1e3a24; + --diff-add-color: #8cc696; + --diff-rem-bg: #4a2626; + --diff-rem-color: #e09690; + --diff-deep-add: #3a7545; + --diff-deep-rem: #8a4444; + --success-bg: #1e3a24; + --error-bg: #4a2626; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); + height: 100vh; + overflow: hidden; + } + + /* App Layout */ + #app { + display: flex; + flex-direction: column; + height: 100vh; + } + + header { + background: var(--bg-secondary); + color: var(--text-primary); + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; + z-index: 10; + box-shadow: 0 2px 4px var(--shadow); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + transition: transform 0.3s ease; + } + + header.hidden { + transform: translateY(-100%); + } + + [data-theme="dark"] header { + background: var(--header-bg); + color: var(--text-inverse); + border-bottom: none; + } + + .header-content h1 { font-size: 1.2rem; margin: 0; } + .header-meta { font-size: 0.85rem; opacity: 0.7; margin-top: 0.2rem; } + .header-meta span { margin-right: 1.5rem; } + .header-meta code { background: var(--bg-tertiary); padding: 0.2rem 0.4rem; border-radius: 3px; border: 1px solid var(--border-color); } + + [data-theme="dark"] .header-meta code { background: rgba(255,255,255,0.1); border: none; } + + /* Theme Toggle */ + .theme-toggle { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 6px; + padding: 0.5rem 0.75rem; + cursor: pointer; + font-size: 1.2rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + } + .theme-toggle:hover { background: var(--hover-color); transform: scale(1.05); } + + [data-theme="dark"] .theme-toggle { + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.2); + color: white; + } + [data-theme="dark"] .theme-toggle:hover { background: rgba(255,255,255,0.2); } + + .container { + display: flex; + flex: 1; + overflow: hidden; + } + + /* Index Pane (Sidebar) */ + .index-pane { + width: 300px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + flex-shrink: 0; + } + + .index-header { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + } + .index-header h2 { font-size: 1rem; margin: 0; } + + .index-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; + list-style: none; + } + + .index-item { + padding: 0.6rem 0.8rem; + margin-bottom: 0.3rem; + border-radius: 6px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s; + font-size: 0.9rem; + border-left: 3px solid transparent; + border: none; + background: var(--bg-primary); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + } + .index-item:hover { + background: var(--bg-tertiary); + box-shadow: 0 2px 4px var(--shadow); + transform: translateX(2px); + } + .index-item.active { + background: var(--accent-color); + color: white; + border-left-color: transparent; + border-color: var(--accent-color); + box-shadow: 0 2px 6px var(--shadow); + } + .index-item.diff-base { + border-left-color: var(--diff-rem-color); + background: rgba(224, 150, 144, 0.1); + border-color: var(--diff-rem-color); + } + .index-item.diff-new { + border-left-color: var(--diff-add-color); + background: rgba(140, 198, 150, 0.1); + border-color: var(--diff-add-color); + } + .index-item.selected { + background: rgba(3, 102, 214, 0.1); + border-left-color: var(--accent-color); + border-color: var(--accent-color); + } + .index-item.active .badge { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.3); color: white; } + + /* Badges */ + .badges { display: flex; gap: 0.3rem; flex-wrap: wrap; } + .badge { font-size: 0.7rem; padding: 0.1rem 0.3rem; border-radius: 3px; font-weight: 600; background: var(--bg-tertiary); border: 1px solid var(--border-color); color: var(--text-primary); } + .badge-error { background: var(--error-bg); border-color: var(--error-color); color: var(--error-color); } + .badge-success { background: var(--success-bg); border-color: var(--success-color); color: var(--success-color); } + + /* Diff Separator - Blends with background as spacing between records */ + .diff-separator { + padding: 0.6rem 1rem; + margin: 0.5rem 0; + background: var(--bg-secondary); + border: none; + cursor: pointer; + transition: all 0.2s; + list-style: none; + } + .diff-separator:hover { + background: var(--bg-tertiary); + border-radius: 4px; + } + .diff-content { + display: flex; + flex-direction: column; + gap: 0.25rem; + opacity: 0.7; + } + .diff-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + } + .diff-label { + font-weight: 500; + color: var(--text-secondary); + } + .diff-stats { + font-family: monospace; + display: flex; + gap: 0.4rem; + } + .stat-add { color: var(--success-color); font-weight: 600; } + .stat-rem { color: var(--error-color); font-weight: 600; } + + /* Main Content */ + .main-pane { + flex: 1; + overflow-y: auto; + padding: 2rem; + position: relative; + } + + /* Record View */ + .record-view h2 { color: var(--accent-color); border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; margin-bottom: 1.5rem; } + + /* Toggleable Sections */ + .toggle-section { border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; margin-bottom: 1rem; } + .toggle-header { background: var(--bg-tertiary); padding: 0.75rem 1rem; font-size: 1rem; font-weight: 600; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; cursor: pointer; } + .toggle-header.collapsed { border-bottom: none; } + .toggle-header:hover { background: var(--border-color); } + .toggle-title { font-size: 1rem; } + .toggle-content { padding: 1rem; overflow-x: auto; background: var(--bg-secondary); } + .toggle-content.hidden { display: none; } + .copy-btn { + font-size: 0.75rem; + padding: 0.3rem 0.6rem; + border: none; + background: var(--bg-secondary); + color: var(--accent-fg); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-weight: 500; + } + .copy-btn:hover { + background: var(--bg-primary); + transform: translateY(-3px); + box-shadow: 0 2px 4px var(--shadow); + } + .copy-btn.copied { + background: var(--success-color); + } + + /* Tables */ + table.kv-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + } + .kv-table th, .kv-table td { + padding: 0.4rem 0.8rem; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + .kv-table td { font-family: var(--font-mono); } + .kv-table tr:last-child th, .kv-table tr:last-child td { border-bottom: none; } + .kv-table th { color: var(--text-secondary); font-weight: normal; background: var(--bg-tertiary); } + + /* Regular KV tables - 40/60 split */ + .kv-table:not(.comparison-table) th { width: 40%; } + + /* Comparison tables - equal width columns */ + .kv-table.comparison-table { + table-layout: fixed; + } + .kv-table.comparison-table th, + .kv-table.comparison-table td { + width: auto; + } + + .split-view { display: flex; height: 100%; gap: 1rem; } + .split-pane { flex: 1; overflow: auto; border-right: 1px dashed var(--border-color); padding-right: 0.5rem; } + .split-pane:last-child { border-right: none; padding-right: 0; } + .split-pane h3 { font-size: 0.9rem; margin-bottom: 0.5rem; color: var(--accent-color); } + + /* Utility */ + .hidden { display: none; } + .btn { padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; border: 1px solid var(--border-color); background: var(--bg-tertiary); color: var(--text-primary); } + .btn:hover { background: var(--bg-secondary); } + .btn-sm { padding: 0.2rem 0.5rem; border-radius: 4px; cursor: pointer; border: 1px solid var(--border-color); background: var(--bg-tertiary); font-size: 0.8rem; margin-left: 0.5rem; color: var(--text-primary); } + .btn-sm:hover { background: var(--bg-secondary); } + .loading { padding: 2rem; text-align: center; color: var(--text-secondary); font-style: italic; } + + .clickable-name { cursor: pointer; color: var(--link-color); text-decoration: underline; } + .clickable-name:hover { color: var(--link-hover); } + + /* Toast Notifications */ + .toast { + position: fixed; + bottom: 2rem; + right: 2rem; + background: var(--bg-secondary); + color: var(--text-primary); + padding: 0.8rem 1.2rem; + border-radius: 6px; + box-shadow: 0 4px 12px var(--shadow); + border: 1px solid var(--border-color); + opacity: 0; + transform: translateY(1rem); + transition: all 0.3s ease; + z-index: 1000; + font-size: 0.9rem; + } + .toast.show { + opacity: 1; + transform: translateY(0); + } + .toast.toast-success { + border-left: 4px solid var(--success-color); + } + .toast.toast-error { + border-left: 4px solid var(--error-color); + } diff --git a/backends/qualcomm/debugger/observatory/templates/js/00_state.js b/backends/qualcomm/debugger/observatory/templates/js/00_state.js new file mode 100644 index 00000000000..d609471f870 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/00_state.js @@ -0,0 +1,16 @@ +(function() { + const OBS = (window.__observatory = window.__observatory || {}); + + OBS.state = { + data: window.OBSERVATORY_DATA || {}, + activeRecordIndex: -1, + theme: localStorage.getItem('graphCollectorTheme') || 'light', + viewPrefs: JSON.parse(localStorage.getItem('graphCollectorViewPrefs') || '{}'), + selectionMode: false, + selectedIndices: new Set(), + mountedViewers: [], + mountedCompares: [], + }; + + OBS.app = document.getElementById('app'); +})(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/01_utils.js b/backends/qualcomm/debugger/observatory/templates/js/01_utils.js new file mode 100644 index 00000000000..f70a8c76c6b --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/01_utils.js @@ -0,0 +1,120 @@ +(function() { + const OBS = window.__observatory; + const state = OBS.state; + + function safeStr(val) { + if (val === null || val === undefined) return ''; + if (typeof val === 'object') return JSON.stringify(val); + return String(val); + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text == null ? '' : String(text); + return div.innerHTML; + } + + function showToast(message, type = 'success') { + const existingToast = document.querySelector('.toast'); + if (existingToast) existingToast.remove(); + + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => toast.classList.add('show'), 10); + setTimeout(() => { + toast.classList.remove('show'); + setTimeout(() => toast.remove(), 300); + }, 3000); + } + + function copyTable(tableEl) { + const rows = Array.from(tableEl.querySelectorAll('tr')); + const csv = rows + .map((row) => { + const cols = Array.from(row.querySelectorAll('td, th')); + return cols + .map((col) => `"${col.innerText.replace(/"/g, '""')}"`) + .join(','); + }) + .join('\n'); + + const html = `${tableEl.innerHTML}
`; + + try { + const blobHTML = new Blob([html], { type: 'text/html' }); + const blobText = new Blob([csv], { type: 'text/plain' }); + const clipboardItem = new ClipboardItem({ + 'text/html': blobHTML, + 'text/plain': blobText, + }); + navigator.clipboard + .write([clipboardItem]) + .then(() => showToast('Copied to clipboard!', 'success')) + .catch(() => showToast('Failed to copy', 'error')); + } catch (_e) { + navigator.clipboard + .writeText(csv) + .then(() => showToast('Copied to clipboard!', 'success')) + .catch(() => showToast('Failed to copy', 'error')); + } + } + + function resolveFunction(path) { + if (!path || typeof path !== 'string') return null; + const parts = path.split('.'); + let fn = window; + for (const p of parts) fn = fn && fn[p]; + return typeof fn === 'function' ? fn : null; + } + + function getLensBlocks(record, lensName) { + const lensView = (record.views || {})[lensName]; + if (!lensView || !Array.isArray(lensView.blocks)) return []; + return lensView.blocks.slice().sort((a, b) => Number(a.order || 0) - Number(b.order || 0)); + } + + function toArraySet(maybeSet) { + return Array.from(maybeSet || []).sort((a, b) => a - b); + } + + function buildViewerPayload(graphRef) { + const assets = state.data.graph_assets || {}; + const layers = state.data.graph_layers || {}; + const asset = assets[graphRef] || {}; + return { + base: asset.base || { legend: [], nodes: [], edges: [] }, + extensions: layers[graphRef] || {}, + }; + } + + function destroyGraphRuntime() { + for (const compare of state.mountedCompares) { + try { + if (compare && typeof compare.destroy === 'function') compare.destroy(); + } catch (_e) {} + } + state.mountedCompares = []; + + for (const viewer of state.mountedViewers) { + try { + if (viewer && typeof viewer.destroy === 'function') viewer.destroy(); + } catch (_e) {} + } + state.mountedViewers = []; + } + + OBS.utils = { + safeStr, + escapeHtml, + showToast, + copyTable, + resolveFunction, + getLensBlocks, + toArraySet, + buildViewerPayload, + destroyGraphRuntime, + }; +})(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/02_layout.js b/backends/qualcomm/debugger/observatory/templates/js/02_layout.js new file mode 100644 index 00000000000..5e36e03b095 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/02_layout.js @@ -0,0 +1,150 @@ +(function() { + const OBS = window.__observatory; + const state = OBS.state; + const { escapeHtml } = OBS.utils; + + function renderLayout() { + const icon = state.theme === 'dark' ? '☀️' : '🌙'; + OBS.app.innerHTML = ` +
+
+

${escapeHtml(state.data.title || 'Observatory Report')}

+
+ Generated: ${escapeHtml(state.data.generated_at || '')} +
+
+
+ +
+
+
+ +
+
+ `; + updateIndexHeader(); + } + + function updateIndexHeader() { + const header = document.getElementById('index-header'); + if (!header) return; + + if (state.selectionMode) { + const total = (state.data.records || []).length; + const allSelected = state.selectedIndices.size === total; + header.innerHTML = ` +
+ Selected: ${state.selectedIndices.size} +
+ + +
+
+ `; + return; + } + + header.innerHTML = ` +
+

Collected Graphs (${(state.data.records || []).length})

+ +
+ `; + } + + function renderIndex() { + const list = document.getElementById('index-list'); + if (!list) return; + + const records = state.data.records || []; + let html = ` +
  • + 📊 Run Dashboard +
  • + `; + + records.forEach((rec, idx) => { + const isSelected = state.selectedIndices.has(idx); + const checkbox = state.selectionMode + ? `` + : ''; + + let activeClass = ''; + if (state.selectionMode) { + if (isSelected) activeClass = 'selected'; + } else if (typeof state.activeRecordIndex === 'object' && state.activeRecordIndex !== null) { + if (state.activeRecordIndex.pool && state.activeRecordIndex.pool.includes(idx)) activeClass = 'selected'; + if (state.activeRecordIndex.base === idx) activeClass = 'diff-base'; + if (state.activeRecordIndex.new === idx) activeClass = 'diff-new'; + } else if (state.activeRecordIndex === idx) { + activeClass = 'active'; + } + + const badges = (rec.badges || []) + .map((b) => { + const badgeClass = b.class || 'badge'; + const title = escapeHtml(b.title || b.label || ''); + const label = escapeHtml(b.label || ''); + return `${label}`; + }) + .join(''); + + if (rec.diff_index && Object.keys(rec.diff_index).length > 0 && idx > 0) { + const rows = Object.entries(rec.diff_index) + .map(([key, val]) => { + const text = String(val); + const plusMatch = text.match(/\+(\d+)/); + const minusMatch = text.match(/-(\d+)/); + let stats = ''; + if (plusMatch || minusMatch) { + if (plusMatch) stats += `+${plusMatch[1]}`; + if (minusMatch) stats += `-${minusMatch[1]}`; + } else { + stats = `${escapeHtml(text)}`; + } + return ` +
    + ${escapeHtml(key)} + ${stats} +
    + `; + }) + .join(''); + + html += ` +
  • +
    ${rows}
    +
  • + `; + } + + html += ` +
  • +
    + ${checkbox} +
    +
    ${escapeHtml(rec.name || '')}
    +
    +
    +
    ${badges}
    +
  • + `; + }); + + list.innerHTML = html; + updateIndexHeader(); + } + + OBS.layout = { + renderLayout, + renderIndex, + updateIndexHeader, + }; +})(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js b/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js new file mode 100644 index 00000000000..aabb084584f --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js @@ -0,0 +1,521 @@ +(function() { + const OBS = window.__observatory; + const state = OBS.state; + const { + safeStr, + escapeHtml, + copyTable, + resolveFunction, + getLensBlocks, + toArraySet, + buildViewerPayload, + destroyGraphRuntime, + } = OBS.utils; + + function createSection(title, storageKey, collapsible) { + const isCollapsible = collapsible !== false; + const isCollapsed = isCollapsible && state.viewPrefs[storageKey] === false; + + const section = document.createElement('div'); + section.className = 'toggle-section'; + + const header = document.createElement('div'); + header.className = `toggle-header ${isCollapsed ? 'collapsed' : ''}`; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'toggle-title'; + titleSpan.textContent = title; + header.appendChild(titleSpan); + + const content = document.createElement('div'); + content.className = `toggle-content ${isCollapsed ? 'hidden' : ''}`; + + if (isCollapsible) { + header.onclick = () => { + content.classList.toggle('hidden'); + header.classList.toggle('collapsed'); + state.viewPrefs[storageKey] = content.classList.contains('hidden') ? false : true; + localStorage.setItem('graphCollectorViewPrefs', JSON.stringify(state.viewPrefs)); + }; + } + + section.appendChild(header); + section.appendChild(content); + + return { section, header, content }; + } + + function renderTableContent(content, data) { + const table = document.createElement('table'); + table.className = 'kv-table'; + const tbody = document.createElement('tbody'); + + const entries = Object.entries(data || {}); + if (entries.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 2; + td.textContent = '(empty)'; + tr.appendChild(td); + tbody.appendChild(tr); + } else { + for (const [key, val] of entries) { + const tr = document.createElement('tr'); + const th = document.createElement('th'); + th.textContent = key; + const td = document.createElement('td'); + td.textContent = safeStr(val); + tr.appendChild(th); + tr.appendChild(td); + tbody.appendChild(tr); + } + } + + table.appendChild(tbody); + content.appendChild(table); + return table; + } + + function mountGraphViewer(root, graphRecord, viewerOptions) { + if (!window.FXGraphViewer || !graphRecord || !graphRecord.graph_ref) { + root.innerHTML = '
    FXGraphViewer unavailable or graph_ref missing.
    '; + return null; + } + + const payload = buildViewerPayload(graphRecord.graph_ref); + const defaultLayers = Array.isArray(graphRecord.default_layers) ? graphRecord.default_layers : []; + const defaultColorBy = graphRecord.default_color_by || (defaultLayers.length > 0 ? defaultLayers[0] : 'base'); + + const layoutMode = (viewerOptions || {}).layout_mode || 'full'; + let preset = 'split'; + if (layoutMode === 'compare_compact') preset = 'compact'; + if (layoutMode === 'headless') preset = 'headless'; + + const viewer = FXGraphViewer.create({ + payload, + mount: { root }, + layout: { preset }, + state: { + activeExtensions: defaultLayers, + colorBy: defaultColorBy, + }, + }); + viewer.init(); + + if ((viewerOptions || {}).sidebar_mode === 'hidden' && typeof viewer.setLayout === 'function') { + try { + viewer.setLayout({ panels: { sidebar: { visible: false } } }); + } catch (_e) {} + } + if ((viewerOptions || {}).minimap_mode === 'off' && typeof viewer.setUIVisibility === 'function') { + try { + viewer.setUIVisibility({ minimapToggle: false }); + } catch (_e) {} + } + + state.mountedViewers.push(viewer); + return viewer; + } + + function renderRecordBlock(container, lensName, block, context, analysis) { + const storageKey = `${lensName}:${block.id}`; + const title = block.title || block.id || lensName; + const { section, header, content } = createSection(title, storageKey, block.collapsible); + + if (block.type === 'table') { + const table = renderTableContent(content, block.record && block.record.data); + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.innerText = 'Copy'; + copyBtn.onclick = (e) => { + e.stopPropagation(); + copyTable(table); + }; + header.appendChild(copyBtn); + } else if (block.type === 'html') { + content.innerHTML = (block.record && block.record.content) || ''; + } else if (block.type === 'custom') { + const jsFunc = block.record && block.record.js_func; + const fn = resolveFunction(jsFunc); + if (!fn) { + content.innerHTML = `
    Function ${escapeHtml(jsFunc || '')} not found
    `; + } else { + try { + fn(content, (block.record && block.record.args) || {}, context, analysis); + } catch (err) { + content.innerHTML = `
    JS Error: ${escapeHtml(err.message || String(err))}
    `; + } + } + } else if (block.type === 'graph') { + const graphRoot = document.createElement('div'); + graphRoot.style.height = '640px'; + graphRoot.style.minHeight = '400px'; + graphRoot.style.border = '1px solid var(--border-color)'; + graphRoot.style.borderRadius = '8px'; + graphRoot.style.overflow = 'hidden'; + content.appendChild(graphRoot); + mountGraphViewer(graphRoot, block.record || {}, (block.record && block.record.viewer_options) || {}); + } else { + content.innerHTML = `
    Unsupported block type: ${escapeHtml(block.type || '')}
    `; + } + + container.appendChild(section); + } + + function renderDashboard(container) { + container.innerHTML = '

    Run Dashboard

    '; + + const dashboard = state.data.dashboard || {}; + let hasContent = false; + + for (const [lensName, viewList] of Object.entries(dashboard)) { + const blocks = Array.isArray(viewList && viewList.blocks) + ? viewList.blocks.slice().sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) + : []; + if (blocks.length === 0) continue; + + const analysis = state.data.analysis_results && state.data.analysis_results[lensName]; + const context = { + start: (state.data.session && state.data.session.start_data && state.data.session.start_data[lensName]) || {}, + end: (state.data.session && state.data.session.end_data && state.data.session.end_data[lensName]) || {}, + records: state.data.records || [], + }; + + for (const block of blocks) { + renderRecordBlock(container, lensName, block, context, analysis); + hasContent = true; + } + } + + if (!hasContent) container.innerHTML += '

    No dashboard data available.

    '; + } + + function renderTableCompare(content, entries) { + const allKeys = new Set(); + for (const entry of entries) { + const data = entry.block && entry.block.record && entry.block.record.data; + if (!data || typeof data !== 'object') continue; + Object.keys(data).forEach((k) => allKeys.add(k)); + } + + if (allKeys.size === 0) { + content.innerHTML = '

    No table data to compare.

    '; + return null; + } + + const table = document.createElement('table'); + table.className = 'kv-table comparison-table'; + + const thead = document.createElement('thead'); + const headerRow = document.createElement('tr'); + const th0 = document.createElement('th'); + th0.textContent = 'Property'; + headerRow.appendChild(th0); + + for (const entry of entries) { + const th = document.createElement('th'); + const span = document.createElement('span'); + span.className = 'clickable-name'; + span.textContent = entry.record.name || `record_${entry.idx}`; + span.onclick = (e) => { + e.stopPropagation(); + window.selectRecord(entry.idx, true); + }; + th.appendChild(span); + headerRow.appendChild(th); + } + + thead.appendChild(headerRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + for (const key of Array.from(allKeys).sort()) { + const tr = document.createElement('tr'); + const th = document.createElement('th'); + th.textContent = key; + tr.appendChild(th); + + for (const entry of entries) { + const td = document.createElement('td'); + const data = entry.block && entry.block.record && entry.block.record.data; + td.textContent = data && data[key] !== undefined ? safeStr(data[key]) : '-'; + tr.appendChild(td); + } + + tbody.appendChild(tr); + } + + table.appendChild(tbody); + content.appendChild(table); + return table; + } + + function renderHtmlCompare(content, entries) { + const split = document.createElement('div'); + split.className = 'split-view'; + + for (const entry of entries) { + const pane = document.createElement('div'); + pane.className = 'split-pane'; + + const h3 = document.createElement('h3'); + const span = document.createElement('span'); + span.className = 'clickable-name'; + span.textContent = entry.record.name || `record_${entry.idx}`; + span.onclick = () => window.selectRecord(entry.idx, true); + h3.appendChild(span); + pane.appendChild(h3); + + const html = (entry.block && entry.block.record && entry.block.record.content) || ''; + const div = document.createElement('div'); + div.innerHTML = html; + pane.appendChild(div); + split.appendChild(pane); + } + + content.appendChild(split); + } + + function renderCustomCompare(content, entries, compareSpec, lensName, blockId) { + const jsFunc = compareSpec.js_func || compareSpec.record_js_func || ''; + const fn = resolveFunction(jsFunc); + if (!fn) { + content.innerHTML = `
    Function ${escapeHtml(jsFunc)} not found
    `; + return; + } + + const context = { + indices: entries.map((e) => e.idx), + names: entries.map((e) => e.record.name), + records: entries.map((e) => e.record), + blocks: entries.map((e) => e.block), + lens: lensName, + block_id: blockId, + }; + + try { + fn(content, compareSpec.args || {}, context, state.data.analysis_results && state.data.analysis_results[lensName]); + } catch (err) { + content.innerHTML = `
    JS Error: ${escapeHtml(err.message || String(err))}
    `; + } + } + + function renderGraphCompare(content, entries, compareSpec) { + const maxParallel = Math.max(1, Number(compareSpec.max_parallel || 2)); + const syncEnabledByDefault = compareSpec.sync_toggle !== false; + const selected = entries.slice(0, maxParallel); + + const controls = document.createElement('div'); + controls.style.cssText = 'display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;'; + const syncId = `sync_${Math.random().toString(36).slice(2)}`; + controls.innerHTML = ` + + `; + content.appendChild(controls); + + const split = document.createElement('div'); + split.className = 'split-view'; + content.appendChild(split); + + const viewers = []; + for (const entry of selected) { + const pane = document.createElement('div'); + pane.className = 'split-pane'; + + const h3 = document.createElement('h3'); + const span = document.createElement('span'); + span.className = 'clickable-name'; + span.textContent = entry.record.name || `record_${entry.idx}`; + span.onclick = () => window.selectRecord(entry.idx, true); + h3.appendChild(span); + pane.appendChild(h3); + + const root = document.createElement('div'); + root.style.height = '520px'; + root.style.minHeight = '360px'; + root.style.border = '1px solid var(--border-color)'; + root.style.borderRadius = '8px'; + root.style.overflow = 'hidden'; + pane.appendChild(root); + + const options = Object.assign({}, (entry.block && entry.block.record && entry.block.record.viewer_options) || {}, compareSpec.viewer_options_compare || {}); + const viewer = mountGraphViewer(root, entry.block.record || {}, options); + if (viewer) viewers.push(viewer); + + split.appendChild(pane); + } + + if (viewers.length > 1 && window.FXGraphCompare && typeof FXGraphCompare.create === 'function') { + const syncConfig = { + selection: syncEnabledByDefault, + camera: false, + theme: false, + layers: false, + }; + const compare = FXGraphCompare.create({ + viewers, + layout: { columns: Math.min(maxParallel, viewers.length), compact: true }, + sync: syncConfig, + }); + state.mountedCompares.push(compare); + + const syncInput = document.getElementById(syncId); + if (syncInput) { + syncInput.addEventListener('change', () => { + const enabled = syncInput.checked; + if (typeof compare.setSync === 'function') { + compare.setSync({ selection: enabled, camera: false, theme: false, layers: false }); + } + }); + } + } + } + + function defaultCompareMode(block) { + if (!block) return 'disabled'; + if (block.type === 'table' || block.type === 'html' || block.type === 'graph') return 'auto'; + return 'disabled'; + } + + function renderCompareLens(recordView, lensName, indices) { + const records = state.data.records || []; + const entriesByBlock = new Map(); + + for (const idx of indices) { + const record = records[idx]; + if (!record) continue; + const blocks = getLensBlocks(record, lensName); + for (const block of blocks) { + const id = block.id || `${lensName}_${block.type}`; + if (!entriesByBlock.has(id)) entriesByBlock.set(id, []); + entriesByBlock.get(id).push({ idx, record, block }); + } + } + + const blockEntries = Array.from(entriesByBlock.values()) + .filter((list) => list.length > 0) + .sort((a, b) => Number((a[0].block && a[0].block.order) || 0) - Number((b[0].block && b[0].block.order) || 0)); + + for (const entries of blockEntries) { + if (entries.length < 2) continue; + + const sample = entries[0].block; + const compareSpec = sample.compare || {}; + const mode = compareSpec.mode || defaultCompareMode(sample); + if (mode === 'disabled') continue; + + const blockLabel = sample.title || sample.id || sample.type; + const sectionKey = `cmp:${lensName}:${sample.id}`; + const { section, header, content } = createSection(`Comparison: ${lensName} / ${blockLabel}`, sectionKey, sample.collapsible); + + if (sample.type === 'table' && mode === 'auto') { + const table = renderTableCompare(content, entries); + if (table) { + const copyBtn = document.createElement('button'); + copyBtn.className = 'copy-btn'; + copyBtn.innerText = 'Copy'; + copyBtn.onclick = (e) => { + e.stopPropagation(); + copyTable(table); + }; + header.appendChild(copyBtn); + } + } else if (sample.type === 'html' && mode === 'auto') { + renderHtmlCompare(content, entries); + } else if (sample.type === 'graph' && mode === 'auto') { + renderGraphCompare(content, entries, compareSpec); + } else if (mode === 'custom') { + renderCustomCompare(content, entries, compareSpec, lensName, sample.id); + } else { + content.innerHTML = `

    Compare mode '${escapeHtml(mode)}' for block type '${escapeHtml(sample.type)}' is not supported in minimal runtime.

    `; + } + + recordView.appendChild(section); + } + } + + function renderUnifiedView(container, indices) { + const records = (state.data.records || []).filter((_, idx) => indices.includes(idx)); + const isSingle = indices.length === 1; + const title = isSingle + ? records[0].name + : `Comparison (${indices.map((i) => (state.data.records || [])[i].name).join(' vs ')})`; + + container.innerHTML = `

    ${escapeHtml(title)}

    `; + const recordView = container.querySelector('.record-view'); + + if (isSingle) { + const idx = indices[0]; + const record = (state.data.records || [])[idx]; + let hasContent = false; + + for (const lensName of Object.keys((record && record.views) || {})) { + const blocks = getLensBlocks(record, lensName); + const analysis = state.data.analysis_results && state.data.analysis_results[lensName]; + const context = { index: idx, record }; + for (const block of blocks) { + renderRecordBlock(recordView, lensName, block, context, analysis); + hasContent = true; + } + } + + if (!hasContent) recordView.innerHTML += '

    No views available for this record.

    '; + return; + } + + const allLenses = new Set(); + for (const idx of indices) { + const record = (state.data.records || [])[idx]; + for (const lensName of Object.keys((record && record.views) || {})) { + allLenses.add(lensName); + } + } + + for (const lensName of allLenses) { + renderCompareLens(recordView, lensName, indices); + } + } + + function renderMain() { + destroyGraphRuntime(); + + const container = document.getElementById('main-pane'); + if (!container) return; + container.innerHTML = ''; + + if (state.selectionMode) { + const indices = toArraySet(state.selectedIndices); + if (indices.length === 0) { + container.innerHTML = '
    Select items to compare...
    '; + } else { + renderUnifiedView(container, indices); + } + return; + } + + if (state.activeRecordIndex === -1) { + renderDashboard(container); + return; + } + + if (typeof state.activeRecordIndex === 'object' && state.activeRecordIndex !== null) { + const indices = state.activeRecordIndex.pool + ? state.activeRecordIndex.pool + : [state.activeRecordIndex.base, state.activeRecordIndex.new].filter((x) => Number.isInteger(x)); + renderUnifiedView(container, indices); + return; + } + + renderUnifiedView(container, [state.activeRecordIndex]); + } + + OBS.render = { + renderMain, + renderDashboard, + renderUnifiedView, + mountGraphViewer, + }; +})(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/04_actions.js b/backends/qualcomm/debugger/observatory/templates/js/04_actions.js new file mode 100644 index 00000000000..45eed9e421c --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/04_actions.js @@ -0,0 +1,103 @@ +(function() { + const OBS = window.__observatory; + const state = OBS.state; + const { renderIndex, updateIndexHeader } = OBS.layout; + const { renderMain } = OBS.render; + + function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + state.theme = theme; + localStorage.setItem('graphCollectorTheme', theme); + const icon = document.querySelector('.theme-icon'); + if (icon) icon.textContent = theme === 'dark' ? '☀️' : '🌙'; + } + + function toggleTheme() { + setTheme(state.theme === 'light' ? 'dark' : 'light'); + } + + function showCompareView() { + const indices = Array.from(arguments).filter((n) => Number.isInteger(n)); + state.activeRecordIndex = { pool: indices }; + renderIndex(); + renderMain(); + } + + function selectRecord(index, forceNavigate) { + if (forceNavigate && state.selectionMode) { + state.selectionMode = false; + state.selectedIndices.clear(); + } + + if (state.selectionMode && index !== -1) { + toggleSelect(index); + return; + } + + state.activeRecordIndex = index; + renderIndex(); + renderMain(); + } + + function toggleSelectionMode() { + state.selectionMode = !state.selectionMode; + if (state.selectionMode) { + state.selectedIndices.clear(); + if (typeof state.activeRecordIndex === 'number' && state.activeRecordIndex !== -1) { + state.selectedIndices.add(state.activeRecordIndex); + } else if ( + typeof state.activeRecordIndex === 'object' && + state.activeRecordIndex && + Array.isArray(state.activeRecordIndex.pool) + ) { + for (const idx of state.activeRecordIndex.pool) state.selectedIndices.add(idx); + } + } else { + state.selectedIndices.clear(); + } + + updateIndexHeader(); + renderIndex(); + renderMain(); + } + + function toggleSelectAll() { + const records = state.data.records || []; + if (state.selectedIndices.size === records.length) { + state.selectedIndices.clear(); + } else { + state.selectedIndices = new Set(records.map((_, i) => i)); + } + renderIndex(); + renderMain(); + } + + function toggleSelect(idx, event) { + if (event) event.stopPropagation(); + if (state.selectedIndices.has(idx)) { + state.selectedIndices.delete(idx); + } else { + state.selectedIndices.add(idx); + } + renderIndex(); + renderMain(); + } + + OBS.actions = { + setTheme, + toggleTheme, + showCompareView, + selectRecord, + toggleSelectionMode, + toggleSelectAll, + toggleSelect, + }; + + window.setTheme = setTheme; + window.toggleTheme = toggleTheme; + window.showCompareView = showCompareView; + window.selectRecord = selectRecord; + window.toggleSelectionMode = toggleSelectionMode; + window.toggleSelectAll = toggleSelectAll; + window.toggleSelect = toggleSelect; +})(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/05_bootstrap_api.js b/backends/qualcomm/debugger/observatory/templates/js/05_bootstrap_api.js new file mode 100644 index 00000000000..d8d91acae95 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/templates/js/05_bootstrap_api.js @@ -0,0 +1,143 @@ +(function() { + const OBS = window.__observatory; + const state = OBS.state; + const { renderLayout, renderIndex } = OBS.layout; + const { renderMain, mountGraphViewer } = OBS.render; + const { showToast } = OBS.utils; + const actions = OBS.actions; + + function wrapGraphHandle(viewer) { + return { + setLayers(layerIds) { + if (viewer && typeof viewer.setLayers === 'function') viewer.setLayers(layerIds || []); + }, + setColorBy(layerId) { + if (viewer && typeof viewer.setColorBy === 'function') viewer.setColorBy(layerId); + }, + updateLayerNodeStyle(layerId, nodeId, patch) { + if (!viewer || typeof viewer.patchLayerNodes !== 'function') return; + const payload = {}; + payload[nodeId] = patch || {}; + viewer.patchLayerNodes(layerId, payload); + }, + selectNode(nodeId, opts) { + if (viewer && typeof viewer.selectNode === 'function') viewer.selectNode(nodeId, opts || {}); + }, + zoomToFit() { + if (viewer && typeof viewer.zoomToFit === 'function') viewer.zoomToFit(); + }, + setSyncEnabled(enabled) { + if (viewer && typeof viewer.setState === 'function') { + try { + viewer.setState({ syncSelection: !!enabled }); + } catch (_e) {} + } + }, + enterFullscreen() { + if (viewer && typeof viewer.enterFullscreen === 'function') viewer.enterFullscreen(); + }, + exitFullscreen() { + if (viewer && typeof viewer.exitFullscreen === 'function') viewer.exitFullscreen(); + }, + onNodeSelected(callback) { + if (viewer && typeof viewer.on === 'function') { + viewer.on('selectionchange', callback); + } + }, + destroy() { + if (viewer && typeof viewer.destroy === 'function') viewer.destroy(); + }, + _viewer: viewer, + }; + } + + window.ObservatoryAPI = { + mountGraph(container, graphRef, options) { + let root = container; + if (typeof container === 'string') root = document.querySelector(container); + if (!root) throw new Error('mountGraph: container not found'); + + const graphRecord = { + graph_ref: graphRef, + default_layers: (options && options.default_layers) || [], + default_color_by: options && options.default_color_by, + viewer_options: (options && options.viewer_options) || {}, + }; + + const viewer = mountGraphViewer(root, graphRecord, graphRecord.viewer_options); + if (!viewer) throw new Error('mountGraph: failed to mount viewer'); + return wrapGraphHandle(viewer); + }, + + selectRecord(index) { + actions.selectRecord(index, true); + }, + + openCompare(indices) { + if (!Array.isArray(indices) || indices.length === 0) return; + state.activeRecordIndex = { pool: indices.slice() }; + renderIndex(); + renderMain(); + }, + + showSingleRecord(index) { + actions.selectRecord(index, true); + }, + + showToast(message, type) { + showToast(message, type || 'success'); + }, + + getContext() { + return { + activeRecordIndex: state.activeRecordIndex, + selectionMode: state.selectionMode, + selectedIndices: Array.from(state.selectedIndices), + records: state.data.records || [], + }; + }, + }; + + function setupDelegatedActions() { + document.body.addEventListener('click', (event) => { + const target = event.target && event.target.closest && event.target.closest('[data-ob-action]'); + if (!target) return; + + const action = target.getAttribute('data-ob-action'); + if (action === 'select-record') { + const rec = Number(target.getAttribute('data-ob-record')); + if (Number.isInteger(rec)) actions.selectRecord(rec, true); + return; + } + + if (action === 'open-compare') { + const raw = target.getAttribute('data-ob-indices') || ''; + const indices = raw + .split(',') + .map((v) => Number(v.trim())) + .filter((v) => Number.isInteger(v)); + if (indices.length > 0) window.ObservatoryAPI.openCompare(indices); + return; + } + + if (action === 'graph-focus-node') { + const nodeId = target.getAttribute('data-ob-node-id'); + if (!nodeId) return; + const firstViewer = state.mountedViewers && state.mountedViewers[0]; + if (firstViewer && typeof firstViewer.selectNode === 'function') { + firstViewer.selectNode(nodeId, { center: true, animate: true }); + } + } + }); + } + + function init() { + actions.setTheme(state.theme); + renderLayout(); + renderIndex(); + renderMain(); + setupDelegatedActions(); + } + + init(); +})(); From 6da102c1d39396a14c780d643581e5e749362319 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 17 Mar 2026 22:10:44 +0800 Subject: [PATCH 16/65] observatory: add demos, UI harness, and smoke tests --- backends/qualcomm/debugger/README.md | 23 ++ .../examples/OBSERVATORY_UI_TESTCASES.md | 35 ++ .../examples/demo_etrecord_auto_collect.py | 86 ++++ .../demo_graphview_accuracy_compare.py | 383 ++++++++++++++++++ .../examples/generate_ui_test_harness.py | 227 +++++++++++ .../observatory/tests/test_graph_hub.py | 46 +++ .../tests/test_observatory_smoke.py | 36 ++ 7 files changed, 836 insertions(+) create mode 100644 backends/qualcomm/debugger/observatory/examples/OBSERVATORY_UI_TESTCASES.md create mode 100644 backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py create mode 100644 backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py create mode 100644 backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py create mode 100644 backends/qualcomm/debugger/observatory/tests/test_graph_hub.py create mode 100644 backends/qualcomm/debugger/observatory/tests/test_observatory_smoke.py diff --git a/backends/qualcomm/debugger/README.md b/backends/qualcomm/debugger/README.md index fb8f9a1c662..589c7793ff8 100644 --- a/backends/qualcomm/debugger/README.md +++ b/backends/qualcomm/debugger/README.md @@ -90,6 +90,29 @@ Note: Files ending with `.bin ` do not support graph visualization in qairt_visu For more details, visit the [QAIRT Visualizer](https://pypi.org/project/qairt-visualizer/). +# Observatory (GraphView Minimal, RFC-aligned) + +A new, review-focused Observatory implementation is available under: + +`backends/qualcomm/debugger/observatory` + +Key properties: +1. Uses RFC-style view contracts: `ViewBlock`, `ViewList`, and `GraphView`. +2. Keeps UI behavior close to legacy observatory while splitting JS into topic files. +3. Supports ETRecord auto-collection through context-managed monkey patching. +4. Migrates minimal built-in lenses for v1: metadata and stack trace (plus canonical graph lens). + +Quick demo: + +```bash +source ~/executorch/.venv/bin/activate +source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh +export PYTHONPATH=~/ +python backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py \ + --model toy --output-dir /tmp/observatory_graphview_demo +``` + + # ExecuTorch QNN Intermediate Output Debugger ExecuTorch QNN Intermediate Output Debugger is a tool that helps users debug intermediate output accuracy by comparing CPU outputs with QNN outputs. This tool offers a variety of output formats and flexibility for users to define their own metrics when debugging. diff --git a/backends/qualcomm/debugger/observatory/examples/OBSERVATORY_UI_TESTCASES.md b/backends/qualcomm/debugger/observatory/examples/OBSERVATORY_UI_TESTCASES.md new file mode 100644 index 00000000000..719155a5a08 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/examples/OBSERVATORY_UI_TESTCASES.md @@ -0,0 +1,35 @@ +# Observatory UI Testcases + +This document describes the interactive JS/HTML harness generated by: + +```bash +python backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py +``` + +## Purpose +1. Validate core block rendering (`table`, `custom`, `graph`). +2. Validate compare mode auto behavior for `table` and `graph` blocks. +3. Validate delegated actions (`select-record`, `open-compare`, `graph-focus-node`). +4. Validate `window.ObservatoryAPI` wiring and fx_viewer mount behavior. + +## What To Verify In Browser +1. Dashboard renders default HTML block. +2. Selecting a record renders table + custom + graph blocks. +3. Clicking custom button `Go Compare 0,1` opens compare view. +4. Compare view shows merged table and side-by-side graph compare. +5. Sync toggle on graph compare enables/disables selection sync. + +## Target APIs Per Check +1. Block rendering path: + - `renderRecordBlock` and `renderCompareLens` in `templates/js/03_blocks.js` +2. Delegated actions: + - `setupDelegatedActions` in `templates/js/05_bootstrap_api.js` +3. Public API: + - `window.ObservatoryAPI.mountGraph` + - `window.ObservatoryAPI.selectRecord` + - `window.ObservatoryAPI.openCompare` + +## Notes +1. Harness uses small static graph payload for deterministic behavior. +2. Harness is meant for interaction validation and API wiring smoke tests. +3. Functional model-accuracy behavior is covered by `demo_graphview_accuracy_compare.py`. diff --git a/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py b/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py new file mode 100644 index 00000000000..e26d6b8b208 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Demonstrate ETRecord monkey-patch auto collection for Observatory. + +This script intentionally avoids manual `Observatory.collect(...)` for graph capture. +When context is enabled, ETRecord API calls are patched and collected automatically. + +Run from repository root: + + source ~/executorch/.venv/bin/activate + source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh + export PYTHONPATH=~/ + + python backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py \ + --output-dir /tmp/observatory_etrecord_demo +""" + +from __future__ import annotations + +import argparse +import json +import os + +import torch + +from executorch.backends.qualcomm.debugger.observatory import Observatory +from executorch.devtools.etrecord import ETRecord + + +class _DemoModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.fc1 = torch.nn.Linear(16, 16) + self.fc2 = torch.nn.Linear(16, 4) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = torch.relu(self.fc1(x)) + return self.fc2(x) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="ETRecord auto-collection demo") + parser.add_argument("--output-dir", default="/tmp/observatory_etrecord_demo") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + os.makedirs(args.output_dir, exist_ok=True) + + model = _DemoModel().eval() + sample_inputs = (torch.rand(1, 16),) + exported_program = torch.export.export(model, sample_inputs, strict=False) + + Observatory.clear() + + with Observatory.enable_context(): + # No manual Observatory.collect call here. + etrecord = ETRecord() + etrecord.add_exported_program(exported_program) + + html_path = os.path.join(args.output_dir, "etrecord_auto_collect_report.html") + json_path = os.path.join(args.output_dir, "etrecord_auto_collect_report.json") + + Observatory.export_html_report(html_path, title="ETRecord Auto Collect Demo") + Observatory.export_json(json_path) + + print( + json.dumps( + { + "report_html": html_path, + "report_json": json_path, + "collected_records": Observatory.list_collected(), + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py b/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py new file mode 100644 index 00000000000..b735d12d7db --- /dev/null +++ b/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Minimal Observatory GraphView demo with per-layer accuracy compare support. + +Run from repository root: + + source ~/executorch/.venv/bin/activate + source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh + export PYTHONPATH=~/ + + python backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py \ + --model toy --output-dir /tmp/observatory_demo + + python backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py \ + --model swin --output-dir /tmp/observatory_demo_swin +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import random +from dataclasses import dataclass +from typing import Any, Dict + +import torch + +from executorch.backends.qualcomm.debugger.observatory import Observatory +from executorch.backends.qualcomm.debugger.observatory.interfaces import ( + Frontend, + Lens, + ObservationContext, + ViewBlock, + ViewList, +) + + +@dataclass +class AccuracyGraphArtifact: + """Artifact wrapper consumed by GraphLens + AccuracyLayerLens.""" + + graph_module: torch.fx.GraphModule + accuracy_layer: Dict[str, Any] + + +class AccuracyLayerLens(Lens): + """Demo lens that contributes fx_viewer overlay from per-layer metrics.""" + + @classmethod + def get_name(cls) -> str: + return "accuracy" + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + if isinstance(artifact, AccuracyGraphArtifact): + return artifact.accuracy_layer + return None + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return observation + + @classmethod + def contribute_graph_layers(cls, digest: Any, context: Dict[str, Any], graph_context: Dict[str, Any]): + if not digest: + return [] + return [ + { + "id": "accuracy/error", + "name": "Accuracy Error", + "legend": digest.get("legend", []), + "nodes": digest.get("nodes", {}), + } + ] + + class AccuracyFrontend(Frontend): + def record(self, digest, analysis, context): + if not digest: + return None + + summary = { + "nodes_with_metrics": len((digest.get("nodes") or {}).keys()), + "max_mse": digest.get("max_mse", 0.0), + "mean_mse": digest.get("mean_mse", 0.0), + } + return ViewList( + blocks=[ + ViewBlock( + id="accuracy_summary", + title="Accuracy Summary", + type="table", + record={"data": summary}, + compare={"mode": "auto"}, + order=20, + ) + ] + ) + + @staticmethod + def get_frontend_spec() -> Frontend: + return AccuracyLayerLens.AccuracyFrontend() + + +class _ToyModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.features = torch.nn.Sequential( + torch.nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=1), + torch.nn.ReLU(), + torch.nn.Conv2d(16, 32, kernel_size=3, stride=2, padding=1), + torch.nn.GELU(), + torch.nn.AdaptiveAvgPool2d((1, 1)), + ) + self.classifier = torch.nn.Linear(32, 10) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = self.features(x) + x = torch.flatten(x, 1) + return self.classifier(x) + + +def _build_toy_model() -> tuple[torch.nn.Module, tuple[int, ...]]: + return _ToyModel().eval().to("cpu"), (1, 3, 128, 128) + + +def _patch_swin_window_ops() -> None: + from transformers.models.swin import modeling_swin + + def window_partition(input_feature: torch.Tensor, window_size: int) -> torch.Tensor: + batch_size, height, width, num_channels = input_feature.shape + input_feature = input_feature.view( + batch_size, + height // window_size, + window_size, + width // window_size, + window_size * num_channels, + ) + windows = input_feature.permute(0, 1, 3, 2, 4).contiguous() + return windows.view(-1, window_size, window_size, num_channels) + + def window_reverse( + windows: torch.Tensor, window_size: int, height: int, width: int + ) -> torch.Tensor: + num_channels = windows.shape[-1] + windows = windows.view( + -1, + height // window_size, + width // window_size, + window_size, + window_size * num_channels, + ) + windows = windows.permute(0, 1, 3, 2, 4).contiguous() + return windows.view(-1, height, width, num_channels) + + modeling_swin.window_partition = window_partition + modeling_swin.window_reverse = window_reverse + + +def _build_swin_model() -> tuple[torch.nn.Module, tuple[int, ...]]: + from transformers import SwinConfig, SwinForImageClassification + + _patch_swin_window_ops() + config = SwinConfig( + image_size=224, + patch_size=4, + num_channels=3, + embed_dim=64, + depths=[1, 1, 1, 1], + num_heads=[2, 4, 8, 16], + window_size=7, + num_labels=10, + ) + return SwinForImageClassification(config).eval().to("cpu"), (1, 3, 224, 224) + + +def _set_seed(seed: int) -> None: + random.seed(seed) + torch.manual_seed(seed) + + +def _fake_quantize_tensor(tensor: torch.Tensor, num_bits: int = 8) -> torch.Tensor: + if tensor.numel() == 0: + return tensor + qmax = (1 << (num_bits - 1)) - 1 + max_abs = tensor.detach().abs().max() + if float(max_abs) == 0.0: + return tensor + scale = max_abs / float(qmax) + q = (tensor / scale).round().clamp(-qmax, qmax) + return q * scale + + +def _make_fake_quantized_copy(model: torch.nn.Module) -> torch.nn.Module: + quantized = copy.deepcopy(model) + with torch.no_grad(): + for p in quantized.parameters(): + p.copy_(_fake_quantize_tensor(p)) + return quantized.eval().to("cpu") + + +def _trace_graph_module(model: torch.nn.Module) -> torch.fx.GraphModule: + graph_module = torch.fx.symbolic_trace(model) + handle = 1 + for node in graph_module.graph.nodes: + if node.op in ("placeholder", "output"): + continue + node.meta["debug_handle"] = handle + handle += 1 + return graph_module + + +def _capture_outputs( + graph_module: torch.fx.GraphModule, sample_inputs: tuple[torch.Tensor, ...] +) -> Dict[str, Any]: + class NodeOutputCapturer(torch.fx.Interpreter): + def __init__(self, module: torch.fx.GraphModule) -> None: + super().__init__(module) + self.outputs: Dict[str, Any] = {} + + def run_node(self, n: torch.fx.Node) -> Any: + result = super().run_node(n) + if n.op not in ("placeholder", "output"): + self.outputs[n.name] = result + return result + + capturer = NodeOutputCapturer(graph_module) + capturer.run(*sample_inputs) + return capturer.outputs + + +def _as_flat_tensor(value: Any) -> torch.Tensor | None: + if isinstance(value, torch.Tensor): + return value.detach().cpu().to(torch.float64).reshape(-1) + if isinstance(value, (tuple, list)) and value: + tensors = [_as_flat_tensor(v) for v in value] + tensors = [t for t in tensors if t is not None] + if tensors: + return torch.cat(tensors) + return None + + +def _score_color(value: float, max_value: float) -> str: + if max_value <= 0.0: + return "#93c5fd" + ratio = min(1.0, max(0.0, value / max_value)) + r = int(147 + (185 - 147) * ratio) + g = int(197 - (197 - 28) * ratio) + b = int(253 - (253 - 28) * ratio) + return f"#{r:02x}{g:02x}{b:02x}" + + +def _build_accuracy_layer( + reference_graph: torch.fx.GraphModule, + candidate_graph: torch.fx.GraphModule, + sample_inputs: tuple[torch.Tensor, ...], +) -> Dict[str, Any]: + reference_outputs = _capture_outputs(reference_graph, sample_inputs) + candidate_outputs = _capture_outputs(candidate_graph, sample_inputs) + + node_scores: Dict[str, float] = {} + for node_id, candidate_value in candidate_outputs.items(): + if node_id not in reference_outputs: + continue + + ref_flat = _as_flat_tensor(reference_outputs[node_id]) + cand_flat = _as_flat_tensor(candidate_value) + if ref_flat is None or cand_flat is None: + continue + + size = min(ref_flat.numel(), cand_flat.numel()) + if size == 0: + continue + ref_flat = ref_flat[:size] + cand_flat = cand_flat[:size] + + mse = float(torch.mean((cand_flat - ref_flat) ** 2).item()) + node_scores[node_id] = mse + + max_mse = max(node_scores.values()) if node_scores else 0.0 + mean_mse = sum(node_scores.values()) / len(node_scores) if node_scores else 0.0 + + nodes = {} + for node_id, mse in node_scores.items(): + nodes[node_id] = { + "info": {"mse": mse}, + "label_append": [f"mse={mse:.4e}"], + "fill_color": _score_color(mse, max_mse), + } + + return { + "legend": [ + {"label": "Low Error", "color": "#93c5fd"}, + {"label": "High Error", "color": "#b91c1c"}, + ], + "nodes": nodes, + "max_mse": max_mse, + "mean_mse": mean_mse, + } + + +def _empty_accuracy_layer() -> Dict[str, Any]: + return { + "legend": [ + {"label": "Low Error", "color": "#93c5fd"}, + {"label": "High Error", "color": "#b91c1c"}, + ], + "nodes": {}, + "max_mse": 0.0, + "mean_mse": 0.0, + } + + +def _build_model(model_name: str) -> tuple[torch.nn.Module, tuple[int, ...]]: + if model_name == "toy": + return _build_toy_model() + if model_name == "swin": + return _build_swin_model() + raise ValueError(f"Unsupported model: {model_name}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Observatory GraphView accuracy compare demo") + parser.add_argument("--model", choices=["toy", "swin"], default="toy") + parser.add_argument("--output-dir", default="/tmp/observatory_graphview_demo") + parser.add_argument("--seed", type=int, default=123) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + os.makedirs(args.output_dir, exist_ok=True) + + _set_seed(args.seed) + model, input_shape = _build_model(args.model) + sample = (torch.rand(*input_shape),) + + reference_model = model + candidate_model = _make_fake_quantized_copy(model) + + reference_graph = _trace_graph_module(reference_model) + candidate_graph = _trace_graph_module(candidate_model) + + accuracy_candidate = _build_accuracy_layer(reference_graph, candidate_graph, sample) + accuracy_reference = _empty_accuracy_layer() + + Observatory.register_lens(AccuracyLayerLens) + Observatory.clear() + + with Observatory.enable_context(): + Observatory.collect( + "Reference Float", + AccuracyGraphArtifact(graph_module=reference_graph, accuracy_layer=accuracy_reference), + ) + Observatory.collect( + "Candidate FakeQuant", + AccuracyGraphArtifact(graph_module=candidate_graph, accuracy_layer=accuracy_candidate), + ) + + html_path = os.path.join(args.output_dir, f"{args.model}_observatory_report.html") + json_path = os.path.join(args.output_dir, f"{args.model}_observatory_report.json") + + Observatory.export_html_report(html_path, title=f"Observatory GraphView Demo ({args.model})") + Observatory.export_json(json_path) + + summary = { + "report_html": html_path, + "report_json": json_path, + "model": args.model, + "max_mse": accuracy_candidate.get("max_mse", 0.0), + "mean_mse": accuracy_candidate.get("mean_mse", 0.0), + } + print(json.dumps(summary, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py b/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py new file mode 100644 index 00000000000..bc9144b4d4d --- /dev/null +++ b/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Generate an interactive observatory UI harness for JS/HTML test cases.""" + +from __future__ import annotations + +import argparse +import json +import os + +from executorch.backends.qualcomm.debugger.observatory.html_template import get_html_template +from executorch.backends.qualcomm.utils.fx_viewer.exporter import FXGraphExporter + + +def _sample_graph_assets() -> dict: + nodes = [ + { + "id": "n0", + "label": "input", + "x": 0, + "y": 0, + "width": 110, + "height": 36, + "info": {"name": "n0", "op": "placeholder", "target": "x"}, + "tooltip": ["Name: n0", "Op: placeholder", "Target: x"], + }, + { + "id": "n1", + "label": "linear", + "x": 200, + "y": 0, + "width": 120, + "height": 36, + "info": {"name": "n1", "op": "call_module", "target": "linear"}, + "tooltip": ["Name: n1", "Op: call_module", "Target: linear"], + }, + ] + edges = [{"v": "n0", "w": "n1", "points": []}] + + return { + "graph_assets": { + "record_0": { + "base": {"legend": [], "nodes": nodes, "edges": edges}, + "meta": {"record_name": "record_0", "node_count": 2}, + }, + "record_1": { + "base": {"legend": [], "nodes": nodes, "edges": edges}, + "meta": {"record_name": "record_1", "node_count": 2}, + }, + }, + "graph_layers": { + "record_0": { + "accuracy/error": { + "name": "Accuracy Error", + "legend": [ + {"label": "Low", "color": "#93c5fd"}, + {"label": "High", "color": "#b91c1c"}, + ], + "nodes": { + "n0": {"info": {"mse": 0.0}, "label_append": ["mse=0.0"], "fill_color": "#93c5fd"}, + "n1": {"info": {"mse": 0.2}, "label_append": ["mse=0.2"], "fill_color": "#b91c1c"}, + }, + } + }, + "record_1": { + "accuracy/error": { + "name": "Accuracy Error", + "legend": [ + {"label": "Low", "color": "#93c5fd"}, + {"label": "High", "color": "#b91c1c"}, + ], + "nodes": { + "n0": {"info": {"mse": 0.0}, "label_append": ["mse=0.0"], "fill_color": "#93c5fd"}, + "n1": {"info": {"mse": 0.6}, "label_append": ["mse=0.6"], "fill_color": "#b91c1c"}, + }, + } + }, + }, + } + + +def _custom_js() -> str: + return """ +window.renderHarnessCustom = function(container, args, context, analysis) { + const p = document.createElement('p'); + p.textContent = 'Custom block says: ' + (args.message || 'hello'); + container.appendChild(p); + const btn = document.createElement('button'); + btn.textContent = 'Go Compare 0,1'; + btn.setAttribute('data-ob-action', 'open-compare'); + btn.setAttribute('data-ob-indices', '0,1'); + container.appendChild(btn); +}; +""" + + +def build_payload() -> dict: + graph_data = _sample_graph_assets() + + blocks_record_0 = { + "blocks": [ + { + "id": "meta", + "title": "Metadata", + "type": "table", + "record": {"data": {"artifact_type": "GM", "node_count": 2}}, + "compare": {"mode": "auto"}, + "order": 0, + "collapsible": True, + }, + { + "id": "custom", + "title": "Custom Action", + "type": "custom", + "record": {"js_func": "renderHarnessCustom", "args": {"message": "record_0"}}, + "compare": {"mode": "disabled"}, + "order": 5, + "collapsible": True, + }, + { + "id": "graph", + "title": "Graph", + "type": "graph", + "record": { + "graph_ref": "record_0", + "default_layers": ["accuracy/error"], + "default_color_by": "accuracy/error", + "viewer_options": {"layout_mode": "full"}, + }, + "compare": { + "mode": "auto", + "max_parallel": 2, + "sync_toggle": True, + "viewer_options_compare": {"layout_mode": "compare_compact"}, + }, + "order": 10, + "collapsible": True, + }, + ] + } + + blocks_record_1 = json.loads(json.dumps(blocks_record_0)) + blocks_record_1["blocks"][0]["record"]["data"]["node_count"] = 3 + blocks_record_1["blocks"][1]["record"]["args"]["message"] = "record_1" + blocks_record_1["blocks"][2]["record"]["graph_ref"] = "record_1" + + resources = { + "js": [FXGraphExporter._load_viewer_js_bundle(), _custom_js()], + "css": [], + } + + payload = { + "title": "Observatory UI Harness", + "generated_at": "N/A", + "resources": resources, + "records": [ + { + "name": "Record 0", + "timestamp": "N/A", + "views": {"tutorial": blocks_record_0}, + "badges": [{"label": "GM", "class": "badge", "title": "GraphModule"}], + "diff_index": {}, + "digests": {}, + }, + { + "name": "Record 1", + "timestamp": "N/A", + "views": {"tutorial": blocks_record_1}, + "badges": [{"label": "GM", "class": "badge", "title": "GraphModule"}], + "diff_index": {"nodes": "+1"}, + "digests": {}, + }, + ], + "dashboard": { + "tutorial": { + "blocks": [ + { + "id": "dashboard", + "title": "Harness Dashboard", + "type": "html", + "record": { + "content": "

    This dashboard validates block rendering and action delegation.

    " + }, + "compare": {"mode": "disabled"}, + "order": 0, + "collapsible": True, + } + ] + } + }, + "analysis_results": {}, + "session": {"start_data": {}, "end_data": {}}, + "graph_assets": graph_data["graph_assets"], + "graph_layers": graph_data["graph_layers"], + } + + return payload + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate observatory UI harness HTML") + parser.add_argument( + "--output", + default="backends/qualcomm/debugger/observatory/examples/observatory_ui_harness.html", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + payload = build_payload() + html = get_html_template(payload["title"], json.dumps(payload)) + + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + f.write(html) + + print(args.output) + + +if __name__ == "__main__": + main() diff --git a/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py b/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py new file mode 100644 index 00000000000..a5f1651be7f --- /dev/null +++ b/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py @@ -0,0 +1,46 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from executorch.backends.qualcomm.debugger.observatory.graph_hub import GraphHub + + +def test_graph_hub_register_and_layers() -> None: + hub = GraphHub() + hub.register_asset( + "r0", + base_payload={"legend": [], "nodes": [{"id": "n0"}], "edges": []}, + meta={"record_name": "r0"}, + ) + hub.add_layers( + "r0", + "accuracy", + [ + { + "id": "error", + "name": "Error", + "legend": [{"label": "L", "color": "#000"}], + "nodes": {"n0": {"fill_color": "#000"}}, + } + ], + ) + + payload = hub.build_payload() + assert "r0" in payload["graph_assets"] + assert "accuracy/error" in payload["graph_layers"]["r0"] + + +def test_build_viewer_payload() -> None: + graph_assets = { + "r1": { + "base": {"legend": [], "nodes": [{"id": "a"}], "edges": []}, + "meta": {}, + } + } + graph_layers = {"r1": {"x/y": {"name": "L", "legend": [], "nodes": {}}}} + + payload = GraphHub.build_viewer_payload(graph_assets, graph_layers, "r1") + assert payload["base"]["nodes"][0]["id"] == "a" + assert "x/y" in payload["extensions"] diff --git a/backends/qualcomm/debugger/observatory/tests/test_observatory_smoke.py b/backends/qualcomm/debugger/observatory/tests/test_observatory_smoke.py new file mode 100644 index 00000000000..5f9fb12562b --- /dev/null +++ b/backends/qualcomm/debugger/observatory/tests/test_observatory_smoke.py @@ -0,0 +1,36 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +import torch + +from executorch.backends.qualcomm.debugger.observatory import Observatory + + +class _SmokeModel(torch.nn.Module): + def __init__(self) -> None: + super().__init__() + self.fc = torch.nn.Linear(4, 4) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.fc(x) + + +def test_observatory_collect_and_export_html(tmp_path) -> None: + Observatory.clear() + + model = _SmokeModel().eval() + graph_module = torch.fx.symbolic_trace(model) + + with Observatory.enable_context(): + Observatory.collect("smoke", graph_module) + + out = tmp_path / "report.html" + Observatory.export_html_report(str(out), title="Smoke") + + assert out.exists() + assert out.stat().st_size > 0 + + Observatory.clear() From 96f61d34350d6ba2e58cb63c65e1ec3c1fb5bed6 Mon Sep 17 00:00:00 2001 From: boyuc Date: Thu, 19 Mar 2026 23:49:45 +0800 Subject: [PATCH 17/65] Integrate fx_graph into observatory - Use python as clean API and function arguments - Fix bug in html_template.py (\n --> \\n) --- .../observatory/IMPLEMENTATION_PLAN.md | 76 -- .../qualcomm/debugger/observatory/README.md | 924 ++++++++++++++++-- .../demo_graphview_accuracy_compare.py | 63 +- .../examples/generate_ui_test_harness.py | 3 +- .../debugger/observatory/graph_hub.py | 42 +- .../debugger/observatory/html_template.py | 8 +- .../debugger/observatory/interfaces.py | 550 ++++++++++- .../debugger/observatory/lenses/metadata.py | 33 +- .../observatory/lenses/stack_trace.py | 14 +- .../debugger/observatory/observatory.py | 158 +-- .../observatory/templates/js/03_blocks.js | 51 +- .../observatory/tests/test_graph_hub.py | 30 +- backends/qualcomm/utils/fx_viewer/__init__.py | 11 +- backends/qualcomm/utils/fx_viewer/exporter.py | 14 +- .../qualcomm/utils/fx_viewer/extension.py | 29 +- backends/qualcomm/utils/fx_viewer/models.py | 22 +- .../fx_viewer/templates/fx_graph_viewer.js | 5 + 17 files changed, 1690 insertions(+), 343 deletions(-) delete mode 100644 backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md diff --git a/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md b/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md deleted file mode 100644 index aae56d1eab2..00000000000 --- a/backends/qualcomm/debugger/observatory/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,76 +0,0 @@ -# Observatory GraphView Minimal Redesign Plan (Context Checkpoint) - -## Branch and Scope -- Working branch: `dev1/boyuc/observatory_graphview_minimal` -- Base context: includes prior `fx_viewer` API refinement commits. -- Final rebase target requested by user: `MLG/dev`. -- Non-goal for this branch: delete old `backends/qualcomm/debugger/debugging_utils`; keep old infra intact and introduce a new review-focused implementation under `backends/qualcomm/debugger/observatory`. - -## User-Approved Reuse Patterns -1. Reuse existing monkey-patch lifecycle pattern for auto collection. -2. Reuse `fx_viewer` runtime APIs for graph mount/compare. -3. Keep RFC class structure (explicitly requested) instead of custom simplified dispatch class hierarchy. -4. Keep serialization/safe-call helpers centralized. - -## Design Constraints From User -1. Preserve original observatory JS/HTML behavior as much as possible. -2. Split JS into separate template files for readability/review. -3. Use RFC contracts (`ViewBlock`, `ViewList`, `GraphView`, `GraphHub`, `GraphLens`) in the new infra. -4. Migrate only minimal lenses at first: metadata and stack trace. -5. No manual `observe()` insertion for ETRecord collection; use monkey-patching to auto insert collection points. -6. Add demo + tutorial/test plan, including swIN-style flow and compare support. - -## Source References Reviewed -- RFC: - - `backends/qualcomm/debugger/debugging_utils/RFC_OBSERVATORY_GRAPHVIEW_INTEGRATION.md` - - `backends/qualcomm/debugger/debugging_utils/FX_VIEWER_ACCURACY_INTEGRATION_PLAN.md` -- Existing observatory infra: - - `backends/qualcomm/debugger/debugging_utils/observatory.py` - - `backends/qualcomm/debugger/debugging_utils/interfaces.py` - - `backends/qualcomm/debugger/debugging_utils/html_template.py` - - `backends/qualcomm/debugger/debugging_utils/extensions/metadata.py` - - `backends/qualcomm/debugger/debugging_utils/extensions/stack_trace.py` - - `backends/qualcomm/debugger/debugging_utils/extensions/adb_execution.py` -- ETRecord integration points: - - `devtools/etrecord/_etrecord.py` -- fx_viewer embedding/runtime: - - `backends/qualcomm/utils/fx_viewer/README.md` - - `backends/qualcomm/utils/fx_viewer/exporter.py` - - `backends/qualcomm/utils/fx_viewer/templates/*.js` -- Existing examples for style and flow: - - `examples/qualcomm/oss_scripts/swin_transformer.py` - - `backends/qualcomm/utils/fx_viewer/examples/demo_per_layer_accuracy_fx.py` - -## Planned Commit Series -1. `observatory(core): scaffold RFC contracts and package layout` - - Add `observatory/interfaces.py`, `graph_hub.py`, package init files. -2. `observatory(lenses): add minimal metadata/stack_trace lenses` - - New lenses under `observatory/lenses/`. -3. `observatory(graph): add GraphLens and graph asset assembly` - - Graph extraction from `GraphModule`/`ExportedProgram` using `FXGraphExporter`. -4. `observatory(ui): split template JS into topic files and preserve current behavior` - - Move old single JS string logic to `observatory/templates/js/*.js`. - - Keep feature parity with old index/dashboard/compare flow. -5. `observatory(auto-collect): ETRecord monkey patch auto collection` - - Install/uninstall patch from context lifecycle. -6. `observatory(demo): add swin-style and GraphView compare demos` - - Add scripts in `observatory/examples/`. -7. `observatory(tests-docs): add tutorial-like test plan and usage docs` - - Add focused test cases and README notes. - -## Execution Checklist -- [ ] Build new observatory runtime and report export path. -- [ ] Verify record view and compare view behavior parity against old template baseline. -- [ ] Verify `GraphView` blocks mount `fx_viewer` correctly. -- [ ] Verify compare-mode graph sync toggle behavior. -- [ ] Verify ETRecord monkey-patch auto collects when context enabled. -- [ ] Run demo scripts with env setup: - - `source ~/executorch/.venv/bin/activate` - - `source ~/executorch/qairt/2.37.0.250724/bin/envsetup.sh` -- [ ] Capture commands/outcomes in docs. - -## Risk Controls -1. Keep old module untouched for rollback and behavior comparison. -2. Stage changes as small commits with runnable checkpoints. -3. Minimize logic churn in first UI pass; mostly mechanical split + explicit API wrappers. -4. Keep unresolved ambiguities in `Questions.md` and proceed with conservative defaults. diff --git a/backends/qualcomm/debugger/observatory/README.md b/backends/qualcomm/debugger/observatory/README.md index d3b012b8478..73c45765a6e 100644 --- a/backends/qualcomm/debugger/observatory/README.md +++ b/backends/qualcomm/debugger/observatory/README.md @@ -1,25 +1,28 @@ # Observatory (GraphView Minimal) -This directory provides a new, review-focused Observatory implementation that follows the GraphView RFC contracts while keeping behavior close to the existing `debugging_utils` UI. - -## Goals -1. Keep implementation simple and easy to review. -2. Preserve original observatory report behavior where practical. -3. Make graph rendering first-class via `GraphView` and `GraphHub`. -4. Keep JS runtime code split into topic files under `templates/js`. -5. Support ETRecord auto-collection through monkey patching while context is enabled. - -## Main Files -1. `interfaces.py`: RFC-style contracts (`ViewBlock`, `ViewList`, `GraphView`, `Lens`, `Frontend`). -2. `observatory.py`: context lifecycle, collection, analysis, report payload assembly. -3. `graph_hub.py`: graph asset/layer registry for `graph_ref`-based reuse. -4. `auto_collect.py`: ETRecord monkey-patch install/uninstall. -5. `lenses/graph.py`: canonical base graph producer (`GraphLens`). -6. `lenses/metadata.py` and `lenses/stack_trace.py`: minimal migrated lenses. -7. `templates/js/*.js`: split UI runtime logic. -8. `templates/css/main.css`: UI styling baseline. - -## Quick Start +This directory contains a new, review-focused observatory runtime under: + +`backends/qualcomm/debugger/observatory` + +The implementation is intentionally breaking and typed. + +## 1. Goals + +1. Keep runtime behavior close to legacy observatory UI. +2. Use strict dataclass contracts instead of raw dict APIs. +3. Make graph rendering first-class via `GraphView` + `GraphHub`. +4. Split JS runtime into topic files for easier review. +5. Support ETRecord auto-collection while context is enabled. + +## 2. Main Entry Points + +1. `__init__.py`: exports `Observatory`. +2. `observatory.py`: runtime lifecycle and report assembly. +3. `interfaces.py`: typed API and contract validation. +4. `graph_hub.py`: graph asset/layer merge logic. +5. `auto_collect.py`: ETRecord monkey-patch auto collection. + +## 3. Quick Start ```bash source ~/executorch/.venv/bin/activate @@ -28,80 +31,873 @@ export PYTHONPATH=~/ ``` ```python +import torch from executorch.backends.qualcomm.debugger.observatory import Observatory +class M(torch.nn.Module): + def __init__(self): + super().__init__() + self.fc = torch.nn.Linear(8, 8) + def forward(self, x): + return self.fc(x) + +model = M().eval() +graph = torch.fx.symbolic_trace(model) + +Observatory.clear() with Observatory.enable_context(): - # collect your graph artifacts - ... + Observatory.collect("step_0", graph) Observatory.export_html_report("/tmp/observatory_report.html") ``` -## Block Contract Example +## 4. Typed Frontend Example ```python -from executorch.backends.qualcomm.debugger.observatory.interfaces import ViewBlock, ViewList - -return ViewList( - blocks=[ - ViewBlock( - id="summary", - title="Summary", - type="table", - record={"data": {"a": 1}}, - compare={"mode": "auto"}, - ) - ] +from executorch.backends.qualcomm.debugger.observatory.interfaces import ( + Frontend, + TableBlock, + TableRecordSpec, + ViewList, ) + +class MyFrontend(Frontend): + def record(self, digest, analysis, context): + return ViewList( + blocks=[ + TableBlock( + id="summary", + title="Summary", + record=TableRecordSpec(data={"nodes": 42}), + order=0, + ) + ] + ) ``` -## GraphView Example +## 5. GraphView Example ```python from executorch.backends.qualcomm.debugger.observatory.interfaces import GraphView -view = GraphView( +graph_block = GraphView( id="acc_graph", title="Accuracy Graph", graph_ref="record_0", default_layers=["accuracy/error"], default_color_by="accuracy/error", +).as_block() +``` + +## 6. Analyze-Step Graph Layer Contribution + +Use `RecordAnalysis.graph_layers` from `analyze()`. + +```python +from executorch.backends.qualcomm.debugger.observatory.interfaces import ( + AnalysisResult, + RecordAnalysis, +) +from executorch.backends.qualcomm.utils.fx_viewer import ( + GraphExtensionPayload, + GraphExtensionNodePayload, ) -block = view.as_block() + +payload = GraphExtensionPayload( + id="error", + name="Accuracy Error", + legend=[{"label": "Low", "color": "#93c5fd"}], + nodes={"node_0": GraphExtensionNodePayload(fill_color="#93c5fd")}, +) + +record_analysis = RecordAnalysis(data={"max_mse": 0.1}) +record_analysis.add_graph_layer("error", payload) + +return AnalysisResult(per_record_data={"step_1": record_analysis}) ``` -## ETRecord Auto-Collection +## 7. ETRecord Auto-Collection + +While inside `enable_context()`, observatory patches ETRecord methods: -`Observatory.enable_context()` installs temporary monkey patches on ETRecord methods: 1. `ETRecord.add_exported_program` 2. `ETRecord.add_edge_dialect_program` 3. `ETRecord.add_extra_export_modules` -These calls automatically trigger Observatory collection while context is active. Patches are removed on outermost context exit. +These calls auto-trigger `Observatory.collect(...)`. + +## 8. Demos -## Demos 1. `examples/demo_graphview_accuracy_compare.py` - - Graph compare with per-layer accuracy overlay. - - Supports `--model toy` and `--model swin`. 2. `examples/demo_etrecord_auto_collect.py` - - Demonstrates zero manual `collect()` for ETRecord paths. 3. `examples/generate_ui_test_harness.py` - - Generates an interactive HTML harness for JS/UI test cases. - -## JS Runtime Layout -1. `templates/js/00_state.js`: state bootstrap. -2. `templates/js/01_utils.js`: utilities + graph payload helpers. -3. `templates/js/02_layout.js`: shell and index rendering. -4. `templates/js/03_blocks.js`: block and compare rendering. -5. `templates/js/04_actions.js`: navigation/theme/selection actions. -6. `templates/js/05_bootstrap_api.js`: init + `window.ObservatoryAPI` + delegated actions. - -## Test Plan -1. Unit-level Python smoke: - - collect toy graph and export HTML. -2. ETRecord injection smoke: - - run `demo_etrecord_auto_collect.py` and verify records are captured. -3. GraphView compare smoke: - - run `demo_graphview_accuracy_compare.py` and compare records in report UI. -4. Interactive JS harness: - - run `generate_ui_test_harness.py` and verify block rendering and actions. + +## 9. Tests + +```bash +pytest -q backends/qualcomm/debugger/observatory/tests +``` + + +## 10. Contract Tables + +The tables below define the actual contracts across lens code, report JSON, +frontend rendering, and custom JS callbacks. + +### 10.1 End-to-End Stage Matrix + +| Stage | Entrypoint / Signature | Primary input | Primary output | Output JSON path | +| --- | --- | --- | --- | --- | +| Runtime capture | `Observatory.collect(name: str, artifact: Any)` | `artifact`, `ObservationContext(config, shared_state)` | `RecordDigest(name, timestamp, data)` | `records[i].digests` | +| Session hooks | `Lens.on_session_start/end(context)` | `ObservationContext` | lens-scoped session payload | `session.start_data[lens]`, `session.end_data[lens]` | +| Analyze | `Lens.analyze(records, config) -> AnalysisResult` | `List[RecordDigest]`, merged config | `global_data`, `per_record_data[name]` | `analysis_results[lens]` | +| Graph merge | `GraphHub.add_analysis_layers(graph_ref, lens_name, record_analysis)` | `RecordAnalysis.graph_layers` | namespaced layer map | `graph_layers[graph_ref]["/"]` | +| Frontend assembly | `Frontend.dashboard(...)`, `Frontend.record(...)` | digest/session/analysis values | `ViewList(blocks=[...])` | `dashboard[lens]`, `records[i].views[lens]` | +| Browser render | `renderMain()/renderUnifiedView()` | full report payload | DOM sections / graph viewers | runtime only | +| Custom JS invoke | `fn(container, args, context, analysis)` | block spec + runtime context | user DOM changes | runtime only | + +### 10.2 Lens Lifecycle Signatures by Stage + +| Stage | Method | Signature | Return / Side Effect | +| --- | --- | --- | --- | +| Registration | `get_name` | `@classmethod get_name() -> str` | stable lens key | +| Registration | `setup` | `@classmethod setup() -> None` | one-time setup | +| Session | `on_session_start` | `@classmethod on_session_start(context: ObservationContext) -> Optional[Serializable]` | stored in `session.start_data[lens]` | +| Runtime | `observe` | `@classmethod observe(artifact: Any, context: ObservationContext) -> Any` | transient observation | +| Runtime | `digest` | `@classmethod digest(observation: Any, context: ObservationContext) -> Serializable` | persisted digest in `RecordDigest.data[lens]` | +| Session | `on_session_end` | `@classmethod on_session_end(context: ObservationContext) -> Optional[Serializable]` | stored in `session.end_data[lens]` | +| Analyze | `analyze` | `@staticmethod analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult` | global + per-record derived data | +| Frontend | `get_frontend_spec` | `@staticmethod get_frontend_spec() -> Frontend` | returns strategy object | +| Reset | `clear` | `@classmethod clear() -> None` | clears lens internal state | + +### 10.3 Frontend Stage Signatures and Input Sources + +| Method | Signature | Python-side argument source | Serialized destination | +| --- | --- | --- | --- | +| `resources` | `resources() -> Dict[str, str]` | lens frontend implementation | `resources.js[]`, `resources.css[]` | +| `dashboard` | `dashboard(start, end, analysis, records) -> Optional[ViewList]` | `start=session.start_data[lens]`, `end=session.end_data[lens]`, `analysis=analysis_results[lens].global_data`, `records=List[RecordDigest]` | `dashboard[lens]` | +| `record` | `record(digest, analysis, context) -> Optional[ViewList]` | `digest=record.data[lens]`, `analysis={"global": global_data, "record": per_record_data[name].data}`, `context={"index", "name"}` | `records[i].views[lens]` | +| `check_badges` | `check_badges(digest, analysis) -> List[Dict[str, str]]` | current digest + `global_data` | `records[i].badges[]` | +| `check_index_diffs` | `check_index_diffs(prev_digest, curr_digest, analysis) -> Dict[str, str]` | previous/current digest + `global_data` | `records[i].diff_index` | + +### 10.4 View Block Contracts (Frontend Output) + +| Block type | Python dataclass | Required record fields | Compare modes | JS renderer path | +| --- | --- | --- | --- | --- | +| Table | `TableBlock(record=TableRecordSpec)` | `record.data: Dict[str, Serializable]` | `auto`, `disabled` | `renderTableContent` | +| HTML | `HtmlBlock(record=HtmlRecordSpec)` | `record.content: str` | `auto`, `disabled` | `content.innerHTML` | +| Custom | `CustomBlock(record=CustomRecordSpec)` | `record.js_func: str`, `record.args: dict` | `custom`, `disabled` | `resolveFunction(js_func)` then callback | +| Graph | `GraphBlock(record=GraphRecordSpec)` | `record.graph_ref: str` | `auto`, `custom`, `disabled` | `mountGraphViewer` | + +| Common block fields | Type | Notes | +| --- | --- | --- | +| `id` | `str` | must be non-empty, unique inside one `ViewList` | +| `title` | `str` | section header text | +| `order` | `int` | stable sort key for rendering | +| `collapsible` | `bool` | section open/close behavior | + +### 10.5 Information Object Map Across Boundaries + +| Python object | Produced in Python stage | JSON path in report | JS access pattern | +| --- | --- | --- | --- | +| `RecordDigest` | `collect` | `records[i]` | `state.data.records[i]` | +| `RecordDigest.data[lens]` | `digest` | `records[i].digests[lens]` | `context.record.digests[lens]` in record custom JS | +| `SessionResult.start_data[lens]` | `on_session_start` | `session.start_data[lens]` | dashboard custom context `start` | +| `SessionResult.end_data[lens]` | `on_session_end` | `session.end_data[lens]` | dashboard custom context `end` | +| `AnalysisResult.global_data` | `analyze` | `analysis_results[lens].global_data` | `analysis.global_data` in custom JS | +| `AnalysisResult.per_record_data[name].data` | `analyze` | `analysis_results[lens].per_record_data[name].data` | `analysis.per_record_data?.[recordName]?.data` | +| `AnalysisResult.per_record_data[name].graph_layers` | `analyze` | merged into `graph_layers[graph_ref]` | included via viewer `extensions` | +| `ViewList` | frontend callbacks | `dashboard[lens].blocks` / `records[i].views[lens].blocks` | `getLensBlocks(record, lensName)` | + +### 10.6 Custom JS Callback Signatures + +| Callback stage | Invocation site | Signature | `context` shape | +| --- | --- | --- | --- | +| Record block render | `renderRecordBlock(..., context={ index, record }, analysis)` | `fn(container, args, context, analysis)` | `{ index: number, record: SerializedRecord }` | +| Dashboard block render | `renderDashboard(..., context={ start, end, records }, analysis)` | `fn(container, args, context, analysis)` | `{ start: object, end: object, records: SerializedRecord[] }` | +| Compare render (`mode="custom"`) | `renderCustomCompare(..., context, analysis)` | `fn(container, args, context, analysis)` | `{ indices, names, records, blocks, lens, block_id }` | + +| Callback arg | Runtime value | Source contract | +| --- | --- | --- | +| `container` | target block DOM container | JS renderer internals | +| `args` | `block.record.args` or `block.compare.args` | `CustomRecordSpec.args` / `CustomCompareSpec.args` | +| `analysis` | `state.data.analysis_results[lensName]` | serialized `AnalysisResult` (`global_data`, `per_record_data`) | + +Example (record view): + +```javascript +function renderRecord(container, args, context, analysis) { + const lensName = "accuracy"; + const digest = context.record?.digests?.[lensName] || {}; + const perRecord = analysis?.per_record_data?.[context.record?.name]?.data || {}; + const global = analysis?.global_data || {}; + container.textContent = `${args.title}: mse_max=${perRecord.max_mse ?? "n/a"}`; +} +``` + +### 10.7 Graph Pipeline (Observatory -> Viewer) + +| Step | Python/JS API | Payload shape | Notes | +| --- | --- | --- | --- | +| Base graph capture | `GraphLens.observe` | `{ graph_ref, base, meta }` | `base` comes from `FXGraphExporter.generate_json_payload()["base"]` | +| Base graph registration | `GraphHub.register_asset(graph_ref, base, meta)` | `graph_assets[graph_ref] = { base, meta }` | one asset per record name by default | +| Layer authoring | `RecordAnalysis.add_graph_layer(key, extension)` | `RecordAnalysis.graph_layers[key]` | `extension` accepts `GraphExtensionPayload` or `GraphExtension` | +| Layer merge | `GraphHub.add_analysis_layers(graph_ref, lens_name, analysis)` | `graph_layers[graph_ref]["/"]` | namespaced IDs prevent cross-lens collisions | +| Viewer payload build | `buildViewerPayload(graphRef)` in `01_utils.js` | `{ base, extensions }` | `base <- graph_assets`, `extensions <- graph_layers` | +| Viewer mount | `FXGraphViewer.create({ payload, mount, layout, state })` | viewer instance | called from `mountGraphViewer` | +| Compare mount | `FXGraphCompare.create({ viewers, layout, sync })` | compare controller | called from `renderGraphCompare` | + +### 10.8 fx_viewer Type Bridge (Python Side) + +| Observatory usage point | fx_viewer type/API | Field-level mapping | +| --- | --- | --- | +| Base graph export | `FXGraphExporter.generate_json_payload()` | uses `.base.legend/nodes/edges` for `graph_assets[graph_ref].base` | +| Layer helper | `GraphExtension(id, name)` | accumulates `nodes_data[node_id]` then `build_payload()` | +| Stable layer payload | `GraphExtensionPayload` | `id`, `name`, `legend`, `nodes[node_id]` | +| Per-node layer payload | `GraphExtensionNodePayload` | `info`, `tooltip`, `label_append`, `fill_color` | +| Observatory conversion | `GraphLayerContribution.to_payload()` | converts `GraphExtension` to `GraphExtensionPayload` and applies overrides | + +### 10.9 fx_viewer Runtime API Used by Observatory (JS Side) + +| Runtime API | Called from observatory JS | Purpose | +| --- | --- | --- | +| `FXGraphViewer.create(config)` | `mountGraphViewer` | mount single graph view | +| `viewer.init()` | `mountGraphViewer` | initialize renderer/UI | +| `viewer.setLayout(patch)` | `mountGraphViewer` | hide sidebar in compact compare layouts | +| `viewer.setUIVisibility(flags)` | `mountGraphViewer` | hide minimap toggle in compare | +| `viewer.setLayers(layerIds)` | `ObservatoryAPI` graph handle | switch active extension layers | +| `viewer.setColorBy(layerId)` | `ObservatoryAPI` graph handle | set color source layer | +| `viewer.patchLayerNodes(layerId, patch)` | `ObservatoryAPI` graph handle | patch node style/info in a layer | +| `viewer.selectNode(nodeId, opts)` | `ObservatoryAPI` + delegated actions | focus node | +| `viewer.zoomToFit()` | `ObservatoryAPI` | reset camera to graph bounds | +| `viewer.enterFullscreen()/exitFullscreen()` | `ObservatoryAPI` | fullscreen control | +| `viewer.on('selectionchange', cb)` | `ObservatoryAPI`, `FXGraphCompare` | selection events + compare sync | +| `FXGraphCompare.create(config)` | `renderGraphCompare` | multi-view synchronization | +| `compare.setSync(patch)` | compare sync checkbox handler | toggle selection sync on/off | + +### 10.10 Graph Layer Naming and Selection Rules + +| Rule | Contract | Example | +| --- | --- | --- | +| Author key (lens analyze code) | free-form local key in `RecordAnalysis.graph_layers` | `"error"` | +| Report-level namespaced key | `"/"` | `"accuracy/error"` | +| Graph block default layers | `GraphRecordSpec.default_layers: list[str]` | `default_layers=["accuracy/error"]` | +| Graph block default color | `GraphRecordSpec.default_color_by: str | None` | `default_color_by="accuracy/error"` | +| Compare viewer options merge | `Object.assign({}, record.viewer_options, compare.viewer_options_compare)` | compare options override record defaults without mutating record options | + +### 10.11 Python-to-JS Dataflow Cheat Sheet + +| You define in Python | Appears in report | Read in JS callback | +| --- | --- | --- | +| `CustomRecordSpec.args` | `block.record.args` | callback `args` | +| `CustomCompareSpec.args` | `block.compare.args` | callback `args` (compare mode) | +| `Frontend.record(..., context={"index","name"})` | selection metadata + serialized record list | callback `context.index`, `context.record` | +| `AnalysisResult.global_data` | `analysis_results[lens].global_data` | callback `analysis.global_data` | +| `AnalysisResult.per_record_data[name].data` | `analysis_results[lens].per_record_data[name].data` | callback `analysis.per_record_data?.[context.record.name]?.data` | + + +## 11. Embedded References (Single Source) + +This README now embeds the former standalone references so contributors can +review runtime behavior and API contracts in one place. + +### 11.1 Architecture Reference + + +Observatory is a whitebox debugging runtime for ExecuTorch compilation and execution flows. +This implementation is intentionally graph-native and typed. + +#### 1. Core Principles + +1. Strict contracts over implicit dicts. +2. Runtime capture separated from offline analysis. +3. Graph assets shared by reference via `graph_ref`. +4. Graph overlays produced in `analyze()` and merged centrally. +5. UI runtime split into topic JS modules for reviewability. + +#### 2. Four-Phase Lifecycle + +##### Phase 1: Runtime Capture + +1. User enters `Observatory.enable_context(...)`. +2. Lenses run `observe()` and `digest()` during `collect(name, artifact)`. +3. Output is persisted as `RecordDigest`. + +##### Phase 2: Session Hooks + +1. Outermost context entry triggers `on_session_start`. +2. Outermost context exit triggers `on_session_end`. +3. ETRecord monkey-patch auto-collection is installed/uninstalled on outermost boundaries. + +##### Phase 3: Analysis + +1. Each lens runs `analyze(records, config)`. +2. Global results go to `AnalysisResult.global_data`. +3. Per-record results go to `AnalysisResult.per_record_data[record_name]` as `RecordAnalysis`. +4. Graph overlay contributions are attached in `RecordAnalysis.graph_layers`. + +##### Phase 4: Report Assembly + Rendering + +1. Frontend blocks are produced from typed `ViewList` contracts. +2. `GraphHub` merges base assets and analysis-time graph overlays. +3. Report payload is exported to JSON and HTML. + +#### 3. Graph-Native Runtime Model + +##### 3.1 Graph Asset Source + +1. `GraphLens` builds one canonical fx_viewer base payload per record. +2. Payload stored in report-level `graph_assets[graph_ref]`. + +##### 3.2 Graph Overlay Source + +1. Lenses attach layers in `analyze()` per record using `RecordAnalysis.graph_layers`. +2. Each layer uses typed fx_viewer payloads (`GraphExtensionPayload`) or `GraphExtension` authoring helper. +3. `GraphHub` namespaces internal layer IDs as `/`. + +##### 3.3 GraphView Consumption + +1. `GraphBlock.record.graph_ref` resolves base graph. +2. `graph_layers[graph_ref]` provides merged overlay layers. +3. Compare mode renders side-by-side viewers with optional selection sync. + +#### 4. UI Runtime Topology + +JS modules under `templates/js`: + +1. `00_state.js`: report state bootstrap. +2. `01_utils.js`: utility + viewer payload helpers. +3. `02_layout.js`: header/sidebar/index rendering. +4. `03_blocks.js`: block renderers + compare behavior. +5. `04_actions.js`: navigation/selection/theme actions. +6. `05_bootstrap_api.js`: app init + `window.ObservatoryAPI`. + +#### 5. Auto-Collection Architecture (ETRecord) + +1. `enable_context` installs ETRecord wrappers. +2. Wrapped ETRecord calls invoke `Observatory.collect(...)` transparently. +3. No manual observe points are required for ETRecord graph capture. +4. Wrappers are restored on outermost context exit. + +#### 6. Breaking API Policy + +This observatory path is intentionally breaking: + +1. Frontend methods must return typed `ViewList` block contracts. +2. Analyze-time graph layers use typed dataclasses, not raw dict hooks. +3. Legacy compatibility shims are intentionally not maintained. + +### 11.2 Python API Reference + + +#### 1. Entry Point + +Import: + +```python +from executorch.backends.qualcomm.debugger.observatory import Observatory +``` + +#### 2. Session Lifecycle + +##### `Observatory.enable_context(config: dict | None = None)` + +Context manager enabling collection. + +Behavior: + +1. Registers default lenses lazily on first use. +2. Installs ETRecord auto-collection wrappers on outermost entry. +3. Calls `on_session_start` hooks on outermost entry. +4. Calls `on_session_end` hooks on outermost exit. +5. Uninstalls ETRecord wrappers on outermost exit. + +Example: + +```python +with Observatory.enable_context(config={"profiling": {"enabled": True}}): + ... +``` + +##### Nested config behavior + +1. Configs are shallow-merged by key. +2. Nested dict values are merged per top-level lens key. +3. Inner context values override outer context values. + +#### 3. Capture APIs + +##### `Observatory.collect(name: str, artifact: Any) -> None` + +Captures one record across all registered lenses. + +Behavior: + +1. No-op if context disabled. +2. Populates `ObservationContext.shared_state["record_name"]`. +3. Executes each lens `observe -> digest` pipeline. +4. Stores `RecordDigest` keyed by `name`. + +##### `Observatory.ignore_graphs(names: list[str]) -> None` + +1. Marks matching names ignored for future collect calls. +2. Removes existing records with matching names. + +##### `Observatory.list_collected() -> list[str]` + +Returns all collected record names. + +##### `Observatory.get(name: str) -> RecordDigest | None` + +Returns one collected record by name. + +#### 4. Lens Registration and Reset + +##### `Observatory.register_lens(lens_cls)` + +Registers a custom lens and runs `lens_cls.setup()` once. + +##### `Observatory.clear() -> None` + +1. Clears all records. +2. Clears session data. +3. Uninstalls ETRecord wrappers. +4. Calls `clear()` on every registered lens. + +#### 5. Export APIs + +##### `Observatory.export_html_report(output_path, title="Observatory Report", config=None)` + +Builds analysis + frontend payload and emits interactive HTML. + +##### `Observatory.export_json(output_path)` + +Exports raw records + session payload only. + +##### `Observatory.generate_html_from_json(json_path, html_path, title="Observatory Report", config=None)` + +Reconstructs HTML report from exported raw JSON and current lens frontend/analyze logic. + +#### 6. Minimal End-to-End Example + +```python +import torch +from executorch.backends.qualcomm.debugger.observatory import Observatory + +class M(torch.nn.Module): + def __init__(self): + super().__init__() + self.fc = torch.nn.Linear(8, 8) + + def forward(self, x): + return self.fc(x) + +model = M().eval() +graph = torch.fx.symbolic_trace(model) + +Observatory.clear() +with Observatory.enable_context(): + Observatory.collect("step_0", graph) + +Observatory.export_html_report("/tmp/observatory_report.html") +``` + +#### 7. Notes + +1. This observatory path is breaking-by-design and does not support legacy dict block contracts. +2. Frontend returns must use typed `ViewList` dataclass API. +3. Graph layers must be attached via `AnalysisResult.per_record_data[record].graph_layers`. + +### 11.3 Interface Reference + + +This document defines strict dataclass contracts used by observatory lenses and UI rendering. + +Source of truth: + +- `backends/qualcomm/debugger/observatory/interfaces.py` + +#### 1. Frontend Block Contracts + +All frontend methods return: + +```python +ViewList(blocks=[...]) +``` + +where each block is one of: + +1. `TableBlock` +2. `HtmlBlock` +3. `CustomBlock` +4. `GraphBlock` + +##### 1.1 TableBlock + +Fields: + +1. base: `id`, `title`, `order`, `collapsible` +2. `record: TableRecordSpec(data: dict[str, Serializable])` +3. `compare: TableCompareSpec(mode: "auto" | "disabled")` + +##### 1.2 HtmlBlock + +Fields: + +1. base: `id`, `title`, `order`, `collapsible` +2. `record: HtmlRecordSpec(content: str)` +3. `compare: HtmlCompareSpec(mode: "auto" | "disabled")` + +##### 1.3 CustomBlock + +Fields: + +1. base: `id`, `title`, `order`, `collapsible` +2. `record: CustomRecordSpec(js_func: str, args: dict)` +3. `compare: CustomCompareSpec(mode: "custom" | "disabled", js_func: str | None, args: dict)` + +Rules: + +1. `record.js_func` must be non-empty. +2. `compare.mode == "custom"` uses `compare.js_func` if set; otherwise falls back to `record.js_func`. + +##### 1.4 GraphBlock + +Fields: + +1. base: `id`, `title`, `order`, `collapsible` +2. `record: GraphRecordSpec` +3. `compare: GraphCompareSpec` + +`GraphRecordSpec` fields: + +1. `graph_ref: str` (required) +2. `default_layers: list[str]` +3. `default_color_by: str | None` +4. `layer_scope: "all" | "lens_only" | list[str]` +5. `viewer_options: dict` +6. `controls: dict` +7. `fullscreen: dict` + +`GraphCompareSpec` fields: + +1. `mode: "auto" | "disabled" | "custom"` +2. `max_parallel: int >= 1` +3. `sync_toggle: bool` +4. `viewer_options_compare: dict` +5. `js_func: str | None` +6. `args: dict` + +#### 2. Validation API + +Utilities: + +1. `validate_view_block(block)` +2. `validate_view_list(view_list)` + +Validation checks: + +1. Non-empty block id/title. +2. Unique block ids in one `ViewList`. +3. CustomBlock function requirements. +4. GraphBlock `graph_ref` and `max_parallel` constraints. + +#### 3. Analysis Contracts + +##### 3.1 `AnalysisResult` + +Fields: + +1. `global_data: dict[str, Serializable]` +2. `per_record_data: dict[str, RecordAnalysis]` + +##### 3.2 `RecordAnalysis` + +Fields: + +1. `data: dict[str, Serializable]` +2. `graph_layers: dict[str, GraphLayerContribution]` + +##### 3.3 `GraphLayerContribution` + +Fields: + +1. `extension`: `GraphExtensionPayload | GraphExtension` +2. `id_override: str | None` +3. `name_override: str | None` + +Method: + +1. `to_payload() -> GraphExtensionPayload` + +Notes: + +1. `GraphExtensionPayload` is preferred for stable serialization. +2. `GraphExtension` is accepted as authoring helper and converted lazily. + +#### 4. Runtime Core Contracts + +##### 4.1 ObservationContext + +1. `config: dict` +2. `shared_state: dict` + +##### 4.2 RecordDigest + +1. `name: str` +2. `timestamp: float` +3. `data: dict[str, Serializable]` + +##### 4.3 SessionResult + +1. `start_data: dict` +2. `end_data: dict` + +#### 5. Lens Protocol Summary + +Each lens may implement: + +1. `setup()` +2. `on_session_start(context)` +3. `observe(artifact, context)` +4. `digest(observation, context)` +5. `on_session_end(context)` +6. `clear()` +7. `analyze(records, config) -> AnalysisResult` +8. `get_frontend_spec() -> Frontend` + +No separate `contribute_graph_layers` hook is used in this architecture. +Graph layers are contributed through `analyze()` via typed `RecordAnalysis`. + +### 11.4 JavaScript API Reference + + +This document describes the host/runtime JS contracts exposed by observatory reports. + +#### 1. CustomBlock JS Contract + +`CustomBlock.record.js_func` signature: + +```javascript +function renderRecord(container, args, context, analysis) { + // container: HTMLElement + // args: static serializable args from Python + // context: { index, record } + // analysis: { global_data, per_record_data } +} +``` + +`CustomBlock.compare` with `mode="custom"` signature: + +```javascript +function renderCompare(container, args, context, analysis) { + // container: HTMLElement + // args: static compare args + // context: { + // indices: number[], + // names: string[], + // records: object[], + // blocks: object[], + // lens: string, + // block_id: string, + // } + // analysis: { global_data, per_record_data } +} +``` + +Behavior: + +1. If `compare.js_func` is set, it is used. +2. If `compare.js_func` is not set, runtime falls back to `record.js_func`. + +#### 2. `window.ObservatoryAPI` + +Defined in `templates/js/05_bootstrap_api.js`. + +##### 2.1 `mountGraph(container, graphRef, options)` + +Mount graph viewer into host container. + +Arguments: + +1. `container`: selector string or `HTMLElement`. +2. `graphRef`: key into report `graph_assets`. +3. `options`: + - `default_layers` + - `default_color_by` + - `viewer_options` + +Returns `GraphHandle` with methods: + +1. `setLayers(layerIds)` +2. `setColorBy(layerId)` +3. `updateLayerNodeStyle(layerId, nodeId, patch)` +4. `selectNode(nodeId, opts)` +5. `zoomToFit()` +6. `setSyncEnabled(enabled)` +7. `enterFullscreen()` +8. `exitFullscreen()` +9. `onNodeSelected(callback)` +10. `destroy()` + +##### 2.2 Navigation helpers + +1. `selectRecord(index)` +2. `openCompare(indices)` +3. `showSingleRecord(index)` + +##### 2.3 Utility helpers + +1. `showToast(message, type)` +2. `getContext()` + +#### 3. Delegated HTML Actions + +Supported action attributes: + +1. `data-ob-action="select-record" data-ob-record="N"` +2. `data-ob-action="open-compare" data-ob-indices="A,B"` +3. `data-ob-action="graph-focus-node" data-ob-node-id="node_name"` + +#### 4. Minimal Example + +```html +
    + +``` + +```javascript +const handle = window.ObservatoryAPI.mountGraph('#graph-slot', 'record_1', { + default_layers: ['accuracy/error'], + default_color_by: 'accuracy/error', +}); + +handle.zoomToFit(); +``` + +### 11.5 Lens-to-GraphHub Guide + + +This guide explains how lenses contribute graph overlays through `analyze()`. + +#### 1. Architectural Rule + +Graph layers are derived data and must be attached in analysis results. + +Do: + +1. Build per-record graph overlays in `analyze()`. +2. Store them in `RecordAnalysis.graph_layers`. + +Do not: + +1. Use separate runtime hooks for layer contribution. +2. Return raw dict layer payloads in user-facing lens APIs. + +#### 2. Preferred Payload Types + +Use fx_viewer typed payload API: + +1. `GraphExtensionPayload` (preferred persisted form) +2. `GraphExtension` (authoring helper, converted lazily) + +Imports: + +```python +from executorch.backends.qualcomm.utils.fx_viewer import ( + GraphExtension, + GraphExtensionPayload, + GraphExtensionNodePayload, +) +``` + +#### 3. Pattern A: Build payload directly + +```python +from executorch.backends.qualcomm.debugger.observatory.interfaces import ( + AnalysisResult, + RecordAnalysis, +) +from executorch.backends.qualcomm.utils.fx_viewer import ( + GraphExtensionPayload, + GraphExtensionNodePayload, +) + +@staticmethod +def analyze(records, config): + per_record = {} + for record in records: + payload = GraphExtensionPayload( + id="error", + name="Accuracy Error", + legend=[{"label": "Low", "color": "#93c5fd"}, {"label": "High", "color": "#b91c1c"}], + nodes={ + "node_0": GraphExtensionNodePayload( + info={"mse": 0.12}, + label_append=["mse=0.12"], + fill_color="#b91c1c", + ) + }, + ) + + analysis = RecordAnalysis(data={"max_mse": 0.12}) + analysis.add_graph_layer("error", payload) + per_record[record.name] = analysis + + return AnalysisResult(per_record_data=per_record) +``` + +#### 4. Pattern B: Use GraphExtension helper + +```python +from executorch.backends.qualcomm.utils.fx_viewer import GraphExtension, NumericColorRule + +ext = GraphExtension(id="latency", name="Layer Latency") +ext.add_node_data("node_0", {"latency_ms": 1.23}) +ext.set_color_rule(NumericColorRule(attribute="latency_ms")) + +analysis = RecordAnalysis(data={"p95_ms": 1.23}) +analysis.add_graph_layer("latency", ext) +``` + +`GraphHub` converts `GraphExtension` to `GraphExtensionPayload` internally. + +#### 5. Layer ID Policy + +User-facing key in `RecordAnalysis`: + +1. `graph_layers["error"] = ...` + +Internal report layer ID by GraphHub: + +1. `/` +2. Example: `accuracy/error` + +This keeps lens APIs free from hardcoded namespacing rules. + +#### 6. How GraphHub Resolves Target Graph + +1. Graph assets are registered by `graph_ref` from graph digest. +2. During payload assembly, observatory reads each record's `RecordAnalysis`. +3. `GraphHub.add_analysis_layers(graph_ref, lens_name, record_analysis)` merges layers for that graph. + +#### 7. Frontend Binding + +`GraphBlock.record.graph_ref` selects which graph asset/layers to render. + +Example: + +```python +GraphView( + id="acc_graph", + title="Accuracy Graph", + graph_ref="Candidate FakeQuant", + default_layers=["accuracy/error"], + default_color_by="accuracy/error", +) +``` + +### 11.6 UI Testcases + +See: +1. `examples/OBSERVATORY_UI_TESTCASES.md` diff --git a/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py b/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py index b735d12d7db..8f0a64df274 100644 --- a/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py +++ b/backends/qualcomm/debugger/observatory/examples/demo_graphview_accuracy_compare.py @@ -34,12 +34,20 @@ from executorch.backends.qualcomm.debugger.observatory import Observatory from executorch.backends.qualcomm.debugger.observatory.interfaces import ( + AnalysisResult, Frontend, Lens, ObservationContext, - ViewBlock, + RecordAnalysis, + RecordDigest, + TableBlock, + TableRecordSpec, ViewList, ) +from executorch.backends.qualcomm.utils.fx_viewer import ( + GraphExtensionNodePayload, + GraphExtensionPayload, +) @dataclass @@ -68,36 +76,59 @@ def digest(cls, observation: Any, context: ObservationContext) -> Any: return observation @classmethod - def contribute_graph_layers(cls, digest: Any, context: Dict[str, Any], graph_context: Dict[str, Any]): - if not digest: - return [] - return [ - { - "id": "accuracy/error", - "name": "Accuracy Error", - "legend": digest.get("legend", []), - "nodes": digest.get("nodes", {}), + def analyze(cls, records: list[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: + per_record: Dict[str, RecordAnalysis] = {} + for record in records: + digest = record.data.get(cls.get_name()) + if not isinstance(digest, dict): + continue + + nodes_payload: Dict[str, GraphExtensionNodePayload] = {} + for node_id, node_value in (digest.get("nodes") or {}).items(): + if not isinstance(node_value, dict): + continue + nodes_payload[node_id] = GraphExtensionNodePayload( + info=node_value.get("info") or {}, + tooltip=node_value.get("tooltip") or [], + label_append=node_value.get("label_append") or [], + fill_color=node_value.get("fill_color"), + ) + + extension_payload = GraphExtensionPayload( + id="error", + name="Accuracy Error", + legend=digest.get("legend") or [], + nodes=nodes_payload, + ) + + summary = { + "nodes_with_metrics": len(nodes_payload), + "max_mse": digest.get("max_mse", 0.0), + "mean_mse": digest.get("mean_mse", 0.0), } - ] + + record_analysis = RecordAnalysis(data=summary) + record_analysis.add_graph_layer("error", extension_payload) + per_record[record.name] = record_analysis + + return AnalysisResult(per_record_data=per_record) class AccuracyFrontend(Frontend): def record(self, digest, analysis, context): if not digest: return None - summary = { + summary = (analysis or {}).get("record") or { "nodes_with_metrics": len((digest.get("nodes") or {}).keys()), "max_mse": digest.get("max_mse", 0.0), "mean_mse": digest.get("mean_mse", 0.0), } return ViewList( blocks=[ - ViewBlock( + TableBlock( id="accuracy_summary", title="Accuracy Summary", - type="table", - record={"data": summary}, - compare={"mode": "auto"}, + record=TableRecordSpec(data=summary), order=20, ) ] diff --git a/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py b/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py index bc9144b4d4d..00474b2915e 100644 --- a/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py +++ b/backends/qualcomm/debugger/observatory/examples/generate_ui_test_harness.py @@ -204,9 +204,10 @@ def build_payload() -> dict: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate observatory UI harness HTML") + default_output = os.path.join(os.path.dirname(__file__), "observatory_ui_harness.html") parser.add_argument( "--output", - default="backends/qualcomm/debugger/observatory/examples/observatory_ui_harness.html", + default=default_output, ) return parser.parse_args() diff --git a/backends/qualcomm/debugger/observatory/graph_hub.py b/backends/qualcomm/debugger/observatory/graph_hub.py index c65eda919ef..1f8fde430e6 100644 --- a/backends/qualcomm/debugger/observatory/graph_hub.py +++ b/backends/qualcomm/debugger/observatory/graph_hub.py @@ -6,7 +6,10 @@ from __future__ import annotations -from typing import Any, Dict, Iterable, List +from dataclasses import asdict +from typing import Any, Dict + +from .interfaces import RecordAnalysis class GraphHub: @@ -24,20 +27,35 @@ def register_asset(self, graph_ref: str, base_payload: Dict[str, Any], meta: Dic "meta": meta or {}, } - def add_layers(self, graph_ref: str, lens_name: str, layers: Iterable[Dict[str, Any]]) -> None: - if not graph_ref: + def add_analysis_layers( + self, + graph_ref: str, + lens_name: str, + analysis: RecordAnalysis | None, + ) -> None: + """Merge per-record analysis graph layers into hub storage. + + Layer IDs are namespaced internally as `/`. + """ + + if not graph_ref or analysis is None: return + slot = self._graph_layers.setdefault(graph_ref, {}) - for layer in layers or []: - layer_id = str(layer.get("id") or "").strip() - if not layer_id: + for layer_key, contribution in analysis.graph_layers.items(): + if not layer_key.strip(): continue - if "/" not in layer_id: - layer_id = f"{lens_name}/{layer_id}" - slot[layer_id] = { - "name": layer.get("name", layer_id), - "legend": layer.get("legend", []), - "nodes": layer.get("nodes", {}), + + payload = contribution.to_payload() + namespaced_id = f"{lens_name}/{layer_key}" + + slot[namespaced_id] = { + "name": payload.name, + "legend": payload.legend, + "nodes": { + node_id: asdict(node_payload) + for node_id, node_payload in payload.nodes.items() + }, } def get_asset(self, graph_ref: str) -> Dict[str, Any]: diff --git a/backends/qualcomm/debugger/observatory/html_template.py b/backends/qualcomm/debugger/observatory/html_template.py index 743383096f1..341ff9ea567 100644 --- a/backends/qualcomm/debugger/observatory/html_template.py +++ b/backends/qualcomm/debugger/observatory/html_template.py @@ -39,12 +39,16 @@ def get_html_template(title: str, payload_json: str) -> str: const res = window.OBSERVATORY_DATA.resources; if (res.css && res.css.length > 0) {{ const style = document.createElement('style'); - style.textContent = res.css.join('\n'); + style.textContent = res.css.map(function(s) {{ + try {{ return atob(s); }} catch(_) {{ return s; }} + }}).join('\\n'); document.head.appendChild(style); }} if (res.js && res.js.length > 0) {{ const script = document.createElement('script'); - script.textContent = res.js.join(';\n'); + script.textContent = res.js.map(function(s) {{ + try {{ return atob(s); }} catch(_) {{ return s; }} + }}).join(';\\n'); document.body.appendChild(script); }} }} diff --git a/backends/qualcomm/debugger/observatory/interfaces.py b/backends/qualcomm/debugger/observatory/interfaces.py index 6ff6ab42622..29728b437c4 100644 --- a/backends/qualcomm/debugger/observatory/interfaces.py +++ b/backends/qualcomm/debugger/observatory/interfaces.py @@ -4,88 +4,486 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +"""Typed API contracts for the Observatory runtime. + +This module is the source-of-truth contract for: +1. Frontend view composition (`ViewList` + typed blocks/specs). +2. Runtime record/session objects (`ObservationContext`, `RecordDigest`, etc.). +3. Analyze-phase graph layer contribution via fx_viewer payload types. + +Architecture model: +1. Runtime phase (`observe`/`digest`) captures raw record data. +2. Analyze phase (`analyze`) computes global and per-record derived data. +3. Frontend phase (`Frontend.*`) maps typed data into renderable view blocks. + +""" + +from __future__ import annotations + from dataclasses import dataclass, field -from typing import Any, Dict, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union +if TYPE_CHECKING: + from executorch.backends.qualcomm.utils.fx_viewer.extension import GraphExtension + from executorch.backends.qualcomm.utils.fx_viewer.models import GraphExtensionPayload + +# Type Alias for JSON-serializable leaf/object values. Serializable = Union[Dict[str, Any], List[Any], str, int, float, bool, None] +# --------------------------------------------------------------------------- +# Frontend block contracts +# --------------------------------------------------------------------------- + + +@dataclass +class TableRecordSpec: + """Record payload for a table block. + + Args: + data: Key-value pairs rendered in the default table renderer. + """ + + data: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class TableCompareSpec: + """Compare behavior for table blocks. + + Modes: + 1. `auto`: runtime renders side-by-side table diff view. + 2. `disabled`: hide compare section for this block. + """ + + mode: Literal["auto", "disabled"] = "auto" + + +@dataclass +class HtmlRecordSpec: + """Record payload for an HTML block. + + Args: + content: Raw HTML fragment rendered into block content container. + """ + + content: str = "" + + +@dataclass +class HtmlCompareSpec: + """Compare behavior for HTML blocks. + + Modes: + 1. `auto`: runtime renders selected HTML blocks side-by-side. + 2. `disabled`: hide compare section for this block. + """ + + mode: Literal["auto", "disabled"] = "auto" + + +@dataclass +class CustomRecordSpec: + """Record payload for a custom JS block. + + JS signature: + function renderRecord(container, args, context, analysis) + + JS argument mapping: + 1. `container`: host DOM container created by observatory runtime. + 2. `args`: exactly this dataclass field (`CustomRecordSpec.args`). + 3. `context`: runtime-selected context object. + - Record view path: `{ index, record }` + - `index`: record index in report payload. + - `record`: full serialized record object from report payload. + - Dashboard path: `{ start, end, records }` + - `start`/`end`: lens session payloads. + - `records`: full serialized records list. + 4. `analysis`: `report.analysis_results[lens_name]` object: + `{ global_data, per_record_data }`. + + Practical access pattern for record callbacks: + 1. Digest: `context.record.digests[lens_name]`. + 2. Per-record analysis: + `analysis.per_record_data?.[context.record.name]?.data`. + 3. Global analysis: `analysis.global_data`. + + Fields: + 1. `js_func`: global function path. + 2. `args`: static serializable args. + """ + + js_func: str = "" + args: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class CustomCompareSpec: + """Compare behavior for custom JS blocks. + + JS signature: + function renderCompare(container, args, context, analysis) + + JS argument mapping: + 1. `container`: compare section DOM container. + 2. `args`: exactly this dataclass field (`CustomCompareSpec.args`). + 3. `context`: + - `indices`: selected global record indices. + - `names`: selected record names. + - `records`: selected serialized record objects. + - `blocks`: selected block payloads for this block ID. + - `lens`: lens name. + - `block_id`: block ID. + 4. `analysis`: `report.analysis_results[lens_name]` object: + `{ global_data, per_record_data }`. + + Fields: + 1. `mode`: `custom` or `disabled`. + 2. `js_func`: compare function path. If omitted in `custom` mode, runtime + falls back to `record.js_func`. + 3. `args`: static compare args. + """ + + mode: Literal["custom", "disabled"] = "disabled" + js_func: Optional[str] = None + args: Dict[str, Serializable] = field(default_factory=dict) + + +GraphLayerScope = Union[Literal["all", "lens_only"], List[str]] + + +@dataclass +class GraphRecordSpec: + """Record payload for GraphView blocks. + + Core fields: + 1. `graph_ref`: key into report `graph_assets` and `graph_layers`. + 2. `default_layers`: initial extension layer IDs. + 3. `default_color_by`: initial color-by layer ID. + 4. `layer_scope`: `all`, `lens_only`, or explicit allowlist. + 5. `viewer_options`: passthrough options for embedded FX viewer. + """ + + graph_ref: str + default_layers: List[str] = field(default_factory=list) + default_color_by: Optional[str] = None + layer_scope: GraphLayerScope = "all" + viewer_options: Dict[str, Serializable] = field(default_factory=dict) + controls: Dict[str, Serializable] = field(default_factory=dict) + fullscreen: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class GraphCompareSpec: + """Compare behavior for graph blocks. + + Modes: + 1. `auto`: runtime mounts side-by-side graph compare with optional sync. + 2. `custom`: call user JS function for compare rendering. + 3. `disabled`: hide compare section. + """ + + mode: Literal["auto", "disabled", "custom"] = "auto" + max_parallel: int = 2 + sync_toggle: bool = True + viewer_options_compare: Dict[str, Serializable] = field( + default_factory=lambda: { + "layout_mode": "compare_compact", + "sidebar_mode": "hidden", + "minimap_mode": "off", + "info_mode": "external", + } + ) + js_func: Optional[str] = None + args: Dict[str, Serializable] = field(default_factory=dict) + + +@dataclass +class TableBlock: + """Typed view block for key-value table rendering.""" + + id: str + title: str + record: TableRecordSpec + compare: TableCompareSpec = field(default_factory=TableCompareSpec) + order: int = 0 + collapsible: bool = True + type: Literal["table"] = "table" + + @dataclass -class ViewBlock: - """Single renderable block for dashboard/record/compare views.""" +class HtmlBlock: + """Typed view block for raw HTML rendering.""" id: str title: str - type: Literal["table", "html", "custom", "graph"] - record: Dict[str, Any] = field(default_factory=dict) - compare: Dict[str, Any] = field(default_factory=dict) + record: HtmlRecordSpec + compare: HtmlCompareSpec = field(default_factory=HtmlCompareSpec) order: int = 0 collapsible: bool = True + type: Literal["html"] = "html" + + +@dataclass +class CustomBlock: + """Typed view block for custom JS rendering.""" + + id: str + title: str + record: CustomRecordSpec + compare: CustomCompareSpec = field(default_factory=CustomCompareSpec) + order: int = 0 + collapsible: bool = True + type: Literal["custom"] = "custom" + + +@dataclass +class GraphBlock: + """Typed view block for graph viewer rendering.""" + + id: str + title: str + record: GraphRecordSpec + compare: GraphCompareSpec = field(default_factory=GraphCompareSpec) + order: int = 0 + collapsible: bool = True + type: Literal["graph"] = "graph" + + +ViewBlock = Union[TableBlock, HtmlBlock, CustomBlock, GraphBlock] @dataclass class ViewList: - """Ordered list of blocks returned by frontends.""" + """Ordered block list returned by lens frontends. + + Rules: + 1. Block IDs must be unique within one ViewList. + 2. Rendering order is controlled by `block.order`. + """ blocks: List[ViewBlock] = field(default_factory=list) @dataclass class GraphView: - """Convenience model for GraphView blocks.""" + """Convenience authoring helper for one graph block. + + This helper is intended for lens authors who want the ergonomics of a + focused graph API while still returning canonical `GraphBlock`. + """ id: str title: str graph_ref: str default_layers: List[str] = field(default_factory=list) default_color_by: Optional[str] = None - layer_scope: Union[str, List[str]] = "all" - viewer_options: Dict[str, Any] = field(default_factory=dict) - controls: Dict[str, Any] = field(default_factory=dict) - fullscreen: Dict[str, Any] = field(default_factory=dict) - compare: Dict[str, Any] = field( - default_factory=lambda: { - "mode": "auto", - "max_parallel": 2, - "sync_toggle": True, - "viewer_options_compare": { - "layout_mode": "compare_compact", - "sidebar_mode": "hidden", - "minimap_mode": "off", - "info_mode": "external", - }, - } - ) + layer_scope: GraphLayerScope = "all" + viewer_options: Dict[str, Serializable] = field(default_factory=dict) + controls: Dict[str, Serializable] = field(default_factory=dict) + fullscreen: Dict[str, Serializable] = field(default_factory=dict) + compare: GraphCompareSpec = field(default_factory=GraphCompareSpec) order: int = 0 collapsible: bool = True - def as_block(self) -> ViewBlock: - """Convert to canonical ViewBlock.""" - record = { - "graph_ref": self.graph_ref, - "default_layers": self.default_layers, - "default_color_by": self.default_color_by, - "layer_scope": self.layer_scope, - "viewer_options": self.viewer_options, - "controls": self.controls, - "fullscreen": self.fullscreen, - } - return ViewBlock( + def as_block(self) -> GraphBlock: + """Build canonical `GraphBlock` from convenience fields.""" + + return GraphBlock( id=self.id, title=self.title, - type="graph", - record=record, + record=GraphRecordSpec( + graph_ref=self.graph_ref, + default_layers=self.default_layers, + default_color_by=self.default_color_by, + layer_scope=self.layer_scope, + viewer_options=self.viewer_options, + controls=self.controls, + fullscreen=self.fullscreen, + ), compare=self.compare, order=self.order, collapsible=self.collapsible, ) +def _require_non_empty_text(value: str, field_name: str, block_id: str) -> None: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"ViewBlock '{block_id}' requires non-empty {field_name}") + + +def _require_str_list(value: Any, field_name: str, block_id: str) -> None: + if not isinstance(value, list) or any((not isinstance(x, str) or not x.strip()) for x in value): + raise ValueError(f"ViewBlock '{block_id}' requires {field_name} as list[str]") + + +def validate_view_block(block: ViewBlock) -> None: + """Validate one typed frontend block. + + Validation covers: + 1. Required identity fields (`id`, `title`). + 2. Per-block invariant checks. + 3. Compare-mode specific requirements (for custom/graph blocks). + """ + + if not isinstance(block, (TableBlock, HtmlBlock, CustomBlock, GraphBlock)): + raise TypeError(f"Unsupported ViewBlock type: {type(block)}") + + _require_non_empty_text(block.id, "id", "") + _require_non_empty_text(block.title, "title", block.id) + + if not isinstance(block.order, int): + raise TypeError(f"ViewBlock '{block.id}' order must be int") + + if isinstance(block, CustomBlock): + _require_non_empty_text(block.record.js_func, "record.js_func", block.id) + if block.compare.mode == "custom": + compare_js_func = (block.compare.js_func or "").strip() or block.record.js_func.strip() + if not compare_js_func: + raise ValueError(f"CustomBlock '{block.id}' compare mode custom requires js_func") + + if isinstance(block, GraphBlock): + _require_non_empty_text(block.record.graph_ref, "record.graph_ref", block.id) + + if block.record.default_layers: + _require_str_list(block.record.default_layers, "record.default_layers", block.id) + + scope = block.record.layer_scope + if isinstance(scope, str): + if scope not in {"all", "lens_only"}: + raise ValueError( + f"GraphBlock '{block.id}' layer_scope must be 'all', 'lens_only', or list[str]" + ) + elif isinstance(scope, list): + _require_str_list(scope, "record.layer_scope", block.id) + else: + raise ValueError( + f"GraphBlock '{block.id}' layer_scope must be 'all', 'lens_only', or list[str]" + ) + + if int(block.compare.max_parallel) < 1: + raise ValueError(f"GraphBlock '{block.id}' compare.max_parallel must be >= 1") + + if block.compare.mode == "custom" and not (block.compare.js_func or "").strip(): + raise ValueError(f"GraphBlock '{block.id}' compare mode custom requires js_func") + + +def validate_view_list(view_list: ViewList) -> None: + """Validate a full frontend `ViewList` contract.""" + + if not isinstance(view_list, ViewList): + raise TypeError(f"Expected ViewList, got {type(view_list)}") + + seen_ids = set() + for block in view_list.blocks: + validate_view_block(block) + if block.id in seen_ids: + raise ValueError(f"Duplicate ViewBlock id in one ViewList: {block.id}") + seen_ids.add(block.id) + + +# --------------------------------------------------------------------------- +# Analysis contracts +# --------------------------------------------------------------------------- + + +@dataclass +class GraphLayerContribution: + """Graph layer contribution attached during analyze-phase. + + `extension` accepts either: + 1. `GraphExtensionPayload` (preferred stable payload type). + 2. `GraphExtension` (authoring helper converted lazily). + """ + + extension: Union["GraphExtension", "GraphExtensionPayload"] + id_override: Optional[str] = None + name_override: Optional[str] = None + + def to_payload(self) -> "GraphExtensionPayload": + """Resolve contribution into a `GraphExtensionPayload`.""" + + from executorch.backends.qualcomm.utils.fx_viewer.extension import GraphExtension + from executorch.backends.qualcomm.utils.fx_viewer.models import GraphExtensionPayload + + payload: GraphExtensionPayload + if isinstance(self.extension, GraphExtensionPayload): + payload = self.extension + elif isinstance(self.extension, GraphExtension): + payload = self.extension.build_payload() + else: + raise TypeError( + "GraphLayerContribution.extension must be GraphExtensionPayload or GraphExtension" + ) + + if self.id_override or self.name_override: + return GraphExtensionPayload( + id=self.id_override or payload.id, + name=self.name_override or payload.name, + legend=payload.legend, + nodes=payload.nodes, + ) + + return payload + + +@dataclass +class RecordAnalysis: + """Per-record analysis output. + + Fields: + 1. `data`: record-specific derived values consumed by frontend record views. + 2. `graph_layers`: map from local layer key to typed graph contribution. + """ + + data: Dict[str, Serializable] = field(default_factory=dict) + graph_layers: Dict[str, GraphLayerContribution] = field(default_factory=dict) + + def add_graph_layer( + self, + key: str, + extension: Union["GraphExtension", "GraphExtensionPayload"], + *, + id_override: Optional[str] = None, + name_override: Optional[str] = None, + ) -> None: + """Add or replace a graph layer contribution for this record.""" + + if not key.strip(): + raise ValueError("RecordAnalysis graph layer key must be non-empty") + self.graph_layers[key] = GraphLayerContribution( + extension=extension, + id_override=id_override, + name_override=name_override, + ) + + +# --------------------------------------------------------------------------- +# Runtime core contracts +# --------------------------------------------------------------------------- + + class Frontend: - """Visualization strategy object returned by each lens.""" + """Visualization strategy object returned by each lens. + + Frontend methods are block-oriented: + 1. `dashboard(...) -> ViewList | None` + 2. `record(...) -> ViewList | None` + + Compare behavior is declared per block (`block.compare`) instead of a + separate lens-level `compare()` callback. + """ def resources(self) -> Dict[str, str]: + """Return optional shared JS/CSS resources. + + Returns: + Dict with optional keys: + 1. `js`: inline JavaScript source. + 2. `css`: inline CSS source. + """ + return {} def dashboard( @@ -95,6 +493,26 @@ def dashboard( analysis: Dict[str, Any], records: List[Any], ) -> Optional[ViewList]: + """Build dashboard-level block list for one lens. + + Python-side inputs: + 1. `start` <- `SessionResult.start_data[lens_name]`. + 2. `end` <- `SessionResult.end_data[lens_name]`. + 3. `analysis` <- `AnalysisResult.global_data` (this lens). + 4. `records` <- collected `RecordDigest` list. + + Render dataflow: + 1. Return `ViewList(blocks=[...])`. + 2. Blocks are serialized into report payload. + 3. For `CustomBlock`, JS callback receives: + `fn(container, block.record.args, {start,end,records}, analysis_results[lens_name])`. + + Args: + start: Session start payload from `on_session_start`. + end: Session end payload from `on_session_end`. + analysis: Lens global analysis payload. + records: Serialized record list for context-aware summaries. + """ return None def record( @@ -103,6 +521,26 @@ def record( analysis: Dict[str, Any], context: Dict[str, Any], ) -> Optional[ViewList]: + """Build record-level block list for one lens. + + Python-side inputs: + 1. `digest` <- current record digest for this lens. + 2. `analysis` <- `{ "global": global_data, "record": per_record_data[name].data }`. + 3. `context` <- `{ "index": int, "name": str }`. + + Render dataflow: + 1. Return `ViewList(blocks=[...])`. + 2. Runtime mounts block renderers per selected record. + 3. For `CustomBlock`, JS callback receives: + `fn(container, block.record.args, {index, record}, analysis_results[lens_name])`. + 4. JS `record` is the serialized report record object, so digest data is + available via `context.record.digests[lens_name]`. + + Args: + digest: Current record digest for this lens. + analysis: Dict with `global` and `record` derived analysis. + context: Record context metadata (`index`, `name`). + """ return None def check_badges(self, digest: Any, analysis: Dict[str, Any]) -> List[Dict[str, str]]: @@ -119,7 +557,11 @@ def check_index_diffs( @dataclass class ObservationContext: - """Context shared across lens runtime hooks.""" + """Context shared across runtime lens hooks. + + `shared_state` is a per-collect broker for cross-lens hints (for example, + exposing record name or artifact hints discovered by one lens). + """ config: Dict[str, Any] shared_state: Dict[str, Any] = field(default_factory=dict) @@ -127,7 +569,10 @@ class ObservationContext: @dataclass class RecordDigest: - """Persistent observation item.""" + """Persistent observation item. + + This is the canonical persisted unit produced by runtime capture. + """ name: str timestamp: float @@ -144,14 +589,20 @@ class SessionResult: @dataclass class AnalysisResult: - """Analysis output for dashboard and record rendering.""" + """Global + per-record analysis contract for a lens.""" global_data: Dict[str, Serializable] = field(default_factory=dict) - per_record_data: Dict[str, Serializable] = field(default_factory=dict) + per_record_data: Dict[str, RecordAnalysis] = field(default_factory=dict) class Lens: - """Protocol for Observatory lenses.""" + """Protocol for Observatory lenses. + + Lifecycle phases: + 1. Runtime (stateful): `setup`, session hooks, `observe`, `digest`, `clear`. + 2. Analyze (pure-data): `analyze(records, config)`. + 3. Frontend strategy: `get_frontend_spec()`. + """ @classmethod def get_name(cls) -> str: @@ -185,15 +636,6 @@ def clear(cls) -> None: def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: return AnalysisResult() - @classmethod - def contribute_graph_layers( - cls, - digest: Any, - context: Dict[str, Any], - graph_context: Dict[str, Any], - ) -> List[Dict[str, Any]]: - return [] - @staticmethod def get_frontend_spec() -> Frontend: return Frontend() diff --git a/backends/qualcomm/debugger/observatory/lenses/metadata.py b/backends/qualcomm/debugger/observatory/lenses/metadata.py index ca80e2a6bd3..c044aff8d69 100644 --- a/backends/qualcomm/debugger/observatory/lenses/metadata.py +++ b/backends/qualcomm/debugger/observatory/lenses/metadata.py @@ -14,7 +14,17 @@ import torch -from ..interfaces import AnalysisResult, Frontend, Lens, ObservationContext, RecordDigest, ViewBlock, ViewList +from ..interfaces import ( + AnalysisResult, + Frontend, + Lens, + ObservationContext, + RecordAnalysis, + RecordDigest, + TableBlock, + TableRecordSpec, + ViewList, +) class MetadataLens(Lens): @@ -67,7 +77,7 @@ def on_session_start(cls, context: ObservationContext) -> Optional[Dict[str, Any @staticmethod def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: - diffs: Dict[str, Dict[str, int]] = {} + per_record: Dict[str, RecordAnalysis] = {} for i in range(len(records) - 1): def _count(rec: RecordDigest) -> int: @@ -79,20 +89,20 @@ def _count(rec: RecordDigest) -> int: before = _count(records[i]) after = _count(records[i + 1]) - diffs[records[i + 1].name] = {"node_diff": after - before} + per_record[records[i + 1].name] = RecordAnalysis( + data={"node_diff": after - before} + ) - return AnalysisResult(per_record_data=diffs) + return AnalysisResult(per_record_data=per_record) class MetadataFrontend(Frontend): def dashboard(self, start, end, analysis, records) -> Optional[ViewList]: return ViewList( blocks=[ - ViewBlock( + TableBlock( id="metadata_dashboard", title="Session Metadata", - type="table", - record={"data": start or {}}, - compare={"mode": "disabled"}, + record=TableRecordSpec(data=start or {}), order=0, ) ] @@ -104,14 +114,13 @@ def record(self, digest, analysis, context) -> Optional[ViewList]: node_diff = record_analysis.get("node_diff", 0) if node_diff: data["nodes_change"] = f"{node_diff:+d}" + return ViewList( blocks=[ - ViewBlock( + TableBlock( id="metadata_record", title="Metadata", - type="table", - record={"data": data}, - compare={"mode": "auto"}, + record=TableRecordSpec(data=data), order=0, ) ] diff --git a/backends/qualcomm/debugger/observatory/lenses/stack_trace.py b/backends/qualcomm/debugger/observatory/lenses/stack_trace.py index 98c7ce80615..527a88dba0b 100644 --- a/backends/qualcomm/debugger/observatory/lenses/stack_trace.py +++ b/backends/qualcomm/debugger/observatory/lenses/stack_trace.py @@ -11,7 +11,7 @@ import os from typing import Any, Dict, List -from ..interfaces import Frontend, Lens, ObservationContext, ViewBlock, ViewList +from ..interfaces import Frontend, HtmlBlock, HtmlRecordSpec, Lens, ObservationContext, ViewList from ..utils import get_git_info, get_repo_root, is_in_repo @@ -77,12 +77,10 @@ def record(self, digest, analysis, context): if not digest: return ViewList( blocks=[ - ViewBlock( + HtmlBlock( id="stack_trace_record", title="Stack Trace", - type="html", - record={"content": "
    No stack trace available
    "}, - compare={"mode": "auto"}, + record=HtmlRecordSpec(content="
    No stack trace available
    "), order=40, ) ] @@ -115,12 +113,10 @@ def record(self, digest, analysis, context): return ViewList( blocks=[ - ViewBlock( + HtmlBlock( id="stack_trace_record", title="Stack Trace", - type="html", - record={"content": "".join(html)}, - compare={"mode": "auto"}, + record=HtmlRecordSpec(content="".join(html)), order=40, ) ] diff --git a/backends/qualcomm/debugger/observatory/observatory.py b/backends/qualcomm/debugger/observatory/observatory.py index dd37edb2192..6515d31dd6a 100644 --- a/backends/qualcomm/debugger/observatory/observatory.py +++ b/backends/qualcomm/debugger/observatory/observatory.py @@ -4,8 +4,20 @@ # This source code is licensed under the BSD-style license found in the # LICENSE file in the root directory of this source tree. +"""Observatory runtime core. + +Lifecycle summary: +1. Runtime capture: `observe -> digest`. +2. Analysis: per-lens `analyze(records, config)`. +3. Assembly: merge frontend blocks + graph assets/layers. +4. Rendering/export: JSON and HTML reports. + +The runtime enforces strict typed interfaces from `interfaces.py`. +""" + from __future__ import annotations +import base64 import copy import json import logging @@ -26,10 +38,11 @@ Frontend, Lens, ObservationContext, + RecordAnalysis, RecordDigest, SessionResult, - ViewBlock, ViewList, + validate_view_list, ) @@ -45,6 +58,8 @@ class Observatory: @classmethod def register_lens(cls, lens_cls: Type[Lens]) -> None: + """Register lens class and run one-time setup.""" + if lens_cls in cls._lens_registry: return cls._lens_registry.append(lens_cls) @@ -55,6 +70,8 @@ def register_lens(cls, lens_cls: Type[Lens]) -> None: @classmethod def _ensure_default_lenses(cls) -> None: + """Lazy-register built-in lenses for the minimal observatory runtime.""" + if cls._lenses_initialized: return @@ -67,15 +84,16 @@ def _ensure_default_lenses(cls) -> None: cls.register_lens(StackTraceLens) cls._lenses_initialized = True - @classmethod - def _merge_session_data(cls, target: Dict[str, Any], source: Optional[Dict[str, Any]]) -> None: - if source: - target.update(source) - @classmethod @contextmanager def enable_context(cls, config: Optional[Dict[str, Any]] = None) -> ContextManager[None]: - """Enable observation context with optional nested overrides.""" + """Enable observation context with nested config overrides. + + Session hooks run once per outermost context: + 1. On first enter, auto-collection patches are installed and + `on_session_start` hooks are called. + 2. On last exit, `on_session_end` hooks are called and patches removed. + """ cls._ensure_default_lenses() @@ -125,12 +143,16 @@ def merge_config_dict(base: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, An @classmethod def _get_current_context(cls) -> Optional[ObservationContext]: + """Return current observation context or None if disabled.""" + if not cls._config_stack: return None return ObservationContext(config=cls._config_stack[-1]) @classmethod def ignore_graphs(cls, names: List[str]) -> None: + """Ignore future collect calls with matching names and drop existing records.""" + for name in names: cls._ignored_graphs.add(name) if name in cls._records: @@ -138,6 +160,13 @@ def ignore_graphs(cls, names: List[str]) -> None: @classmethod def collect(cls, name: str, artifact: Any) -> None: + """Capture one record across all registered lenses. + + Notes: + 1. No-op when context is disabled. + 2. Record name is exposed via `context.shared_state['record_name']`. + """ + if any(ignored in name for ignored in cls._ignored_graphs): return @@ -177,6 +206,8 @@ def get(cls, name: str) -> Optional[RecordDigest]: @classmethod def clear(cls) -> None: + """Clear records/session state and reset lens runtime state.""" + cls._records.clear() cls._session_result = SessionResult() ETRecordAutoCollector.uninstall() @@ -189,25 +220,21 @@ def clear(cls) -> None: @staticmethod def _serialize_view_list(result: Any) -> Optional[Dict[str, Any]]: + """Validate and serialize frontend return values.""" + if result is None: return None - if isinstance(result, ViewBlock): - result = ViewList(blocks=[result]) - if not isinstance(result, ViewList): - raise TypeError(f"Frontend must return ViewList or ViewBlock, got {type(result)}") + raise TypeError(f"Frontend must return ViewList, got {type(result)}") - blocks = [] - for block in result.blocks: - if not isinstance(block, ViewBlock): - raise TypeError(f"ViewList.blocks must contain ViewBlock, got {type(block)}") - blocks.append(asdict(block)) - - return {"blocks": blocks} + validate_view_list(result) + return {"blocks": [asdict(block) for block in result.blocks]} @classmethod def _safe_frontend_call(cls, lens_name: str, method: Any, *args: Any, **kwargs: Any) -> Optional[Dict[str, Any]]: + """Run frontend method with error isolation and fallback error block.""" + try: result = method(*args, **kwargs) return cls._serialize_view_list(result) @@ -219,21 +246,25 @@ def _safe_frontend_call(cls, lens_name: str, method: Any, *args: Any, **kwargs: exc, traceback.format_exc(), ) - error_block = ViewBlock( - id="frontend_error", - title="Frontend Error", - type="html", - record={ - "content": ( - '
    ' - f"Error: {str(exc)}
    " - ) - }, - compare={"mode": "disabled"}, - order=999, - ) - return {"blocks": [asdict(error_block)]} + return { + "blocks": [ + { + "id": "frontend_error", + "title": "Frontend Error", + "type": "html", + "record": { + "content": ( + '
    ' + f"Error: {str(exc)}
    " + ) + }, + "compare": {"mode": "disabled"}, + "order": 999, + "collapsible": True, + } + ] + } @classmethod def _generate_report_payload( @@ -243,6 +274,8 @@ def _generate_report_payload( config: Dict[str, Any], lens_registry: List[Type[Lens]], ) -> Dict[str, Any]: + """Build full report payload including graph assets/layers and views.""" + analysis_results: Dict[str, AnalysisResult] = { lens.get_name(): lens.analyze(records, config) for lens in lens_registry } @@ -261,6 +294,13 @@ def _generate_report_payload( if res.get("css"): resources["css"].append(res["css"]) + resources["js"] = [ + base64.b64encode(s.encode("utf-8")).decode("ascii") for s in resources["js"] + ] + resources["css"] = [ + base64.b64encode(s.encode("utf-8")).decode("ascii") for s in resources["css"] + ] + graph_hub = GraphHub() serialized_records = [] @@ -281,36 +321,23 @@ def _generate_report_payload( continue analysis = analysis_results.get(lens_name, AnalysisResult()) + record_analysis: RecordAnalysis | None = analysis.per_record_data.get(record.name) analysis_ctx = { "global": analysis.global_data, - "record": analysis.per_record_data.get(record.name), + "record": (record_analysis.data if record_analysis else {}), } - if isinstance(digest, dict) and digest.get("graph_ref") and isinstance(digest.get("base"), dict): + graph_ref = record.name + # extract graph data from graph Lense + if lens_name == "graph": + assert isinstance(digest, dict) and isinstance(digest.get("base"), dict), "[Observatory] error validating graph lense output." + assert digest["graph_ref"] == graph_ref, "[Observatory] graph ref should be consistant with record name" graph_hub.register_asset( - str(digest["graph_ref"]), + graph_ref, digest["base"], digest.get("meta", {}), ) - - graph_ref = record.name - if isinstance(digest, dict) and digest.get("graph_ref"): - graph_ref = str(digest["graph_ref"]) - - try: - layers = lens.contribute_graph_layers( - digest, - { - "record_name": record.name, - "record_index": i, - }, - { - "graph_ref": graph_ref, - }, - ) - graph_hub.add_layers(graph_ref, lens_name, layers) - except Exception as exc: - logging.error("[Observatory] Lens %s graph layer contribution failed: %s", lens_name, exc) + graph_hub.add_analysis_layers(graph_ref, lens_name, record_analysis) frontend = lens.get_frontend_spec() try: @@ -361,7 +388,16 @@ def _generate_report_payload( "resources": resources, "records": serialized_records, "dashboard": dashboard_views, - "analysis_results": {k: asdict(v) for k, v in analysis_results.items()}, + "analysis_results": { + key: { + "global_data": value.global_data, + "per_record_data": { + rec_name: {"data": rec_analysis.data} + for rec_name, rec_analysis in value.per_record_data.items() + }, + } + for key, value in analysis_results.items() + }, "session": { "start_data": session.start_data, "end_data": session.end_data, @@ -377,6 +413,8 @@ def export_html_report( title: str = "Observatory Report", config: Optional[Dict[str, Any]] = None, ) -> None: + """Export collected records to HTML report.""" + if not cls._records: logging.warning("[Observatory] No records collected, skipping HTML export") return @@ -393,7 +431,7 @@ def export_html_report( from .html_template import get_html_template - json_data = json.dumps(payload, default=str) + json_data = json.dumps(payload, default=str).replace(" None: + """Export raw records/session data as JSON.""" + if not cls._records: logging.warning("[Observatory] No records collected, skipping JSON export") return @@ -426,6 +466,8 @@ def generate_html_from_json( title: str = "Observatory Report", config: Optional[Dict[str, Any]] = None, ) -> None: + """Generate HTML report from previously exported raw JSON.""" + with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) @@ -444,7 +486,7 @@ def generate_html_from_json( from .html_template import get_html_template - json_data = json.dumps(payload, default=str) + json_data = json.dumps(payload, default=str).replace("FXGraphViewer unavailable or graph_ref missing.
    '; + function resolveViewerCtor() { + if (typeof FXGraphViewer !== 'undefined') return FXGraphViewer; + if (window && window.FXGraphViewer) return window.FXGraphViewer; + return null; + } + + function resolveGraphRef(graphRecord, fallbackGraphRef) { + if (!graphRecord || typeof graphRecord !== 'object') return fallbackGraphRef || ''; + return ( + graphRecord.graph_ref || + graphRecord.graphRef || + graphRecord.record_name || + graphRecord.recordName || + fallbackGraphRef || + '' + ); + } + + function mountGraphViewer(root, graphRecord, viewerOptions, fallbackGraphRef) { + const ViewerCtor = resolveViewerCtor(); + const graphRef = resolveGraphRef(graphRecord, fallbackGraphRef); + + if (!ViewerCtor || !graphRef) { + const reason = !ViewerCtor ? 'FXGraphViewer unavailable' : 'graph_ref missing'; + root.innerHTML = `
    ${reason}.
    `; return null; } - const payload = buildViewerPayload(graphRecord.graph_ref); + const payload = buildViewerPayload(graphRef); const defaultLayers = Array.isArray(graphRecord.default_layers) ? graphRecord.default_layers : []; const defaultColorBy = graphRecord.default_color_by || (defaultLayers.length > 0 ? defaultLayers[0] : 'base'); @@ -91,7 +113,7 @@ if (layoutMode === 'compare_compact') preset = 'compact'; if (layoutMode === 'headless') preset = 'headless'; - const viewer = FXGraphViewer.create({ + const viewer = ViewerCtor.create({ payload, mount: { root }, layout: { preset }, @@ -154,7 +176,8 @@ graphRoot.style.borderRadius = '8px'; graphRoot.style.overflow = 'hidden'; content.appendChild(graphRoot); - mountGraphViewer(graphRoot, block.record || {}, (block.record && block.record.viewer_options) || {}); + const fallbackGraphRef = (context && context.record && context.record.name) || ''; + mountGraphViewer(graphRoot, block.record || {}, (block.record && block.record.viewer_options) || {}, fallbackGraphRef); } else { content.innerHTML = `
    Unsupported block type: ${escapeHtml(block.type || '')}
    `; } @@ -276,8 +299,9 @@ content.appendChild(split); } - function renderCustomCompare(content, entries, compareSpec, lensName, blockId) { - const jsFunc = compareSpec.js_func || compareSpec.record_js_func || ''; + function renderCustomCompare(content, entries, compareSpec, sampleBlock, lensName, blockId) { + const recordJsFunc = sampleBlock && sampleBlock.record && sampleBlock.record.js_func; + const jsFunc = compareSpec.js_func || recordJsFunc || ''; const fn = resolveFunction(jsFunc); if (!fn) { content.innerHTML = `
    Function ${escapeHtml(jsFunc)} not found
    `; @@ -342,20 +366,23 @@ pane.appendChild(root); const options = Object.assign({}, (entry.block && entry.block.record && entry.block.record.viewer_options) || {}, compareSpec.viewer_options_compare || {}); - const viewer = mountGraphViewer(root, entry.block.record || {}, options); + const fallbackGraphRef = (entry.record && entry.record.name) || ''; + const viewer = mountGraphViewer(root, entry.block.record || {}, options, fallbackGraphRef); if (viewer) viewers.push(viewer); split.appendChild(pane); } - if (viewers.length > 1 && window.FXGraphCompare && typeof FXGraphCompare.create === 'function') { + const hasCompareCtor = typeof FXGraphCompare !== 'undefined' || !!(window && window.FXGraphCompare); + if (viewers.length > 1 && hasCompareCtor) { + const CompareCtor = typeof FXGraphCompare !== 'undefined' ? FXGraphCompare : window.FXGraphCompare; const syncConfig = { selection: syncEnabledByDefault, camera: false, theme: false, layers: false, }; - const compare = FXGraphCompare.create({ + const compare = CompareCtor.create({ viewers, layout: { columns: Math.min(maxParallel, viewers.length), compact: true }, sync: syncConfig, @@ -428,7 +455,7 @@ } else if (sample.type === 'graph' && mode === 'auto') { renderGraphCompare(content, entries, compareSpec); } else if (mode === 'custom') { - renderCustomCompare(content, entries, compareSpec, lensName, sample.id); + renderCustomCompare(content, entries, compareSpec, sample, lensName, sample.id); } else { content.innerHTML = `

    Compare mode '${escapeHtml(mode)}' for block type '${escapeHtml(sample.type)}' is not supported in minimal runtime.

    `; } diff --git a/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py b/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py index a5f1651be7f..4356fee8973 100644 --- a/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py +++ b/backends/qualcomm/debugger/observatory/tests/test_graph_hub.py @@ -5,6 +5,11 @@ # LICENSE file in the root directory of this source tree. from executorch.backends.qualcomm.debugger.observatory.graph_hub import GraphHub +from executorch.backends.qualcomm.debugger.observatory.interfaces import RecordAnalysis +from executorch.backends.qualcomm.utils.fx_viewer import ( + GraphExtensionNodePayload, + GraphExtensionPayload, +) def test_graph_hub_register_and_layers() -> None: @@ -14,18 +19,21 @@ def test_graph_hub_register_and_layers() -> None: base_payload={"legend": [], "nodes": [{"id": "n0"}], "edges": []}, meta={"record_name": "r0"}, ) - hub.add_layers( - "r0", - "accuracy", - [ - { - "id": "error", - "name": "Error", - "legend": [{"label": "L", "color": "#000"}], - "nodes": {"n0": {"fill_color": "#000"}}, - } - ], + analysis = RecordAnalysis() + analysis.add_graph_layer( + "error", + GraphExtensionPayload( + id="error", + name="Error", + legend=[{"label": "L", "color": "#000"}], + nodes={ + "n0": GraphExtensionNodePayload( + fill_color="#000", + ) + }, + ), ) + hub.add_analysis_layers("r0", "accuracy", analysis) payload = hub.build_payload() assert "r0" in payload["graph_assets"] diff --git a/backends/qualcomm/utils/fx_viewer/__init__.py b/backends/qualcomm/utils/fx_viewer/__init__.py index 8c99cb7bf76..15b2f133e67 100644 --- a/backends/qualcomm/utils/fx_viewer/__init__.py +++ b/backends/qualcomm/utils/fx_viewer/__init__.py @@ -1,7 +1,14 @@ from .color_rules import ColorRule, CategoricalColorRule, NumericColorRule from .exporter import FXGraphExporter from .extension import GraphExtension -from .models import BaseGraphPayload, GraphEdge, GraphNode, GraphPayload +from .models import ( + BaseGraphPayload, + GraphEdge, + GraphExtensionNodePayload, + GraphExtensionPayload, + GraphNode, + GraphPayload, +) __all__ = [ "FXGraphExporter", @@ -12,5 +19,7 @@ "GraphNode", "GraphEdge", "BaseGraphPayload", + "GraphExtensionNodePayload", + "GraphExtensionPayload", "GraphPayload", ] diff --git a/backends/qualcomm/utils/fx_viewer/exporter.py b/backends/qualcomm/utils/fx_viewer/exporter.py index 26e78345e14..bb9f0b12d1e 100644 --- a/backends/qualcomm/utils/fx_viewer/exporter.py +++ b/backends/qualcomm/utils/fx_viewer/exporter.py @@ -15,7 +15,13 @@ from .grandalf.layouts import SugiyamaLayout from .grandalf.routing import route_with_lines from .grandalf.utils.nx import convert_nextworkx_graph_to_grandalf -from .models import BaseGraphPayload, GraphEdge, GraphNode, GraphPayload +from .models import ( + BaseGraphPayload, + GraphEdge, + GraphExtensionPayload, + GraphNode, + GraphPayload, +) class FXGraphExporter: @@ -237,9 +243,9 @@ def _build_base_payload(self, nodes: dict[str, GraphNode], edges: list[GraphEdge edges=edges, ) - def _build_extensions_payload(self) -> dict[str, Any]: + def _build_extensions_payload(self) -> dict[str, GraphExtensionPayload]: print("Compiling extension payloads...") - return {ext.id: ext.build() for ext in self.extensions} + return {ext.id: ext.build_payload() for ext in self.extensions} def generate_json_payload(self) -> Dict[str, Any]: nodes, edges = self._extract_graph() @@ -273,7 +279,9 @@ def _load_viewer_js_bundle() -> str: path = os.path.join(template_dir, filename) with open(path, "r") as f: chunks.append(f"\n// ---- {filename} ----\n") + chunks.append(f'console.log("Successfully Loaded {path}")') chunks.append(f.read()) + return "\n".join(chunks) def export_js(self, container_id: str) -> str: diff --git a/backends/qualcomm/utils/fx_viewer/extension.py b/backends/qualcomm/utils/fx_viewer/extension.py index c6aabfdea01..bcbdcdb1f4c 100644 --- a/backends/qualcomm/utils/fx_viewer/extension.py +++ b/backends/qualcomm/utils/fx_viewer/extension.py @@ -1,9 +1,11 @@ from __future__ import annotations +from dataclasses import asdict from typing import Dict, Any, Callable, Optional import warnings from .color_rules import ColorRule +from .models import GraphExtensionNodePayload, GraphExtensionPayload class GraphExtension: @@ -66,17 +68,17 @@ def _format_lines( return result - def build(self) -> Dict[str, Any]: + def build_payload(self) -> GraphExtensionPayload: node_colors = {} legend = [] if self.color_rule: node_colors, legend = self.color_rule.apply(self.nodes_data) - compiled_nodes = {} + compiled_nodes: Dict[str, GraphExtensionNodePayload] = {} for node_id, data in self.nodes_data.items(): - compiled = {"info": data} + compiled = GraphExtensionNodePayload(info=data) if self.label_formatter: lines = self._format_lines( @@ -86,7 +88,7 @@ def build(self) -> Dict[str, Any]: kind="label", ) if lines: - compiled["label_append"] = lines + compiled.label_append = lines if self.tooltip_formatter: lines = self._format_lines( @@ -96,15 +98,20 @@ def build(self) -> Dict[str, Any]: kind="tooltip", ) if lines: - compiled["tooltip"] = lines + compiled.tooltip = lines if node_id in node_colors: - compiled["fill_color"] = node_colors[node_id] + compiled.fill_color = node_colors[node_id] compiled_nodes[node_id] = compiled - return { - "name": self.name, - "legend": legend, - "nodes": compiled_nodes, - } + return GraphExtensionPayload( + id=self.id, + name=self.name, + legend=legend, + nodes=compiled_nodes, + ) + + def build(self) -> Dict[str, Any]: + """Backward-compatible dict payload export.""" + return asdict(self.build_payload()) diff --git a/backends/qualcomm/utils/fx_viewer/models.py b/backends/qualcomm/utils/fx_viewer/models.py index 38d3d603186..efe60898a2f 100644 --- a/backends/qualcomm/utils/fx_viewer/models.py +++ b/backends/qualcomm/utils/fx_viewer/models.py @@ -35,7 +35,27 @@ class BaseGraphPayload: edges: list[GraphEdge] +@dataclass +class GraphExtensionNodePayload: + """Wire-format extension node schema.""" + + info: dict[str, Any] = field(default_factory=dict) + tooltip: list[str] = field(default_factory=list) + label_append: list[str] = field(default_factory=list) + fill_color: str | None = None + + +@dataclass +class GraphExtensionPayload: + """Wire-format extension layer schema.""" + + id: str + name: str + legend: list[dict[str, str]] = field(default_factory=list) + nodes: dict[str, GraphExtensionNodePayload] = field(default_factory=dict) + + @dataclass class GraphPayload: base: BaseGraphPayload - extensions: dict[str, Any] + extensions: dict[str, GraphExtensionPayload] diff --git a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js index b853b272011..af8fddd6c44 100644 --- a/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js +++ b/backends/qualcomm/utils/fx_viewer/templates/fx_graph_viewer.js @@ -905,3 +905,8 @@ class FXGraphCompare { this._offs = []; } } + +if (typeof globalThis !== 'undefined') { + globalThis.FXGraphViewer = FXGraphViewer; + globalThis.FXGraphCompare = FXGraphCompare; +} From c76e022f9bcec3b4b3e9d95b4ad513e892c6b406 Mon Sep 17 00:00:00 2001 From: boyuc Date: Fri, 20 Mar 2026 11:03:50 +0800 Subject: [PATCH 18/65] observatory: safe HTML embedding, gzip compression, async resource bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes for HTML report correctness and size: 1. Base64-encode HtmlBlock.content in the JSON payload so and other special characters cannot corrupt the outer - """ diff --git a/backends/qualcomm/debugger/observatory/interfaces.py b/backends/qualcomm/debugger/observatory/interfaces.py index 29728b437c4..5b0a737de1a 100644 --- a/backends/qualcomm/debugger/observatory/interfaces.py +++ b/backends/qualcomm/debugger/observatory/interfaces.py @@ -65,7 +65,9 @@ class HtmlRecordSpec: """Record payload for an HTML block. Args: - content: Raw HTML fragment rendered into block content container. + content: Raw HTML fragment. In the report payload this field is + base64-encoded to prevent special characters from corrupting + the JSON embedding. The runtime decodes it before innerHTML assignment. """ content: str = "" diff --git a/backends/qualcomm/debugger/observatory/observatory.py b/backends/qualcomm/debugger/observatory/observatory.py index 6515d31dd6a..44665166492 100644 --- a/backends/qualcomm/debugger/observatory/observatory.py +++ b/backends/qualcomm/debugger/observatory/observatory.py @@ -19,6 +19,7 @@ import base64 import copy +import gzip import json import logging import os @@ -266,6 +267,35 @@ def _safe_frontend_call(cls, lens_name: str, method: Any, *args: Any, **kwargs: ] } + @staticmethod + def _encode_html_blocks(serialized_records: list, dashboard: dict) -> None: + """Base64-encode HtmlBlock.content strings in-place to prevent JSON corruption.""" + + def _encode_blocks(blocks: list) -> None: + for block in blocks: + if block.get("type") == "html": + content = (block.get("record") or {}).get("content", "") + if content: + block["record"]["content"] = base64.b64encode( + content.encode("utf-8") + ).decode("ascii") + + for record in serialized_records: + for view in (record.get("views") or {}).values(): + _encode_blocks(view.get("blocks") or []) + for view in dashboard.values(): + _encode_blocks(view.get("blocks") or []) + + @staticmethod + def _compress_payload(json_data: str, threshold: int = 8192) -> tuple: + """Gzip+base64 compress JSON payload if above threshold bytes.""" + + raw = json_data.encode("utf-8") + if len(raw) >= threshold: + compressed = gzip.compress(raw, compresslevel=6) + return base64.b64encode(compressed).decode("ascii"), True + return json_data, False + @classmethod def _generate_report_payload( cls, @@ -384,7 +414,7 @@ def _generate_report_payload( graph_payload = graph_hub.build_payload() - return { + payload = { "resources": resources, "records": serialized_records, "dashboard": dashboard_views, @@ -406,6 +436,9 @@ def _generate_report_payload( "graph_layers": graph_payload["graph_layers"], } + Observatory._encode_html_blocks(serialized_records, dashboard_views) + return payload + @classmethod def export_html_report( cls, @@ -432,7 +465,8 @@ def export_html_report( from .html_template import get_html_template json_data = json.dumps(payload, default=str).replace(" Date: Fri, 20 Mar 2026 19:11:56 +0800 Subject: [PATCH 19/65] observatory(ui): fix camera init and add viewer state cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs fixed: 1. Wrong initial camera on first load — viewer.init() was called synchronously before the browser had laid out the container, so getBoundingClientRect() returned {width:0, height:0} and the camera was placed far off-screen. Fixed by deferring init() to the next animation frame (requestAnimationFrame). 2. All viewer state lost on every record switch — destroyGraphRuntime() was unconditionally destroying every live viewer. Camera, selected node, active layers, colorBy, and zoom were all reset. Fix: hybrid viewer cache. Single-record graph blocks use a live DOM cache keyed by (recordIndex, lensName, blockId). On navigate-away the wrapper is detached from the DOM but the viewer stays alive in state.viewerCache. On return the wrapper is re-appended and a resize rAF is queued — no re-init, no re-layout, full state preserved. LRU eviction at 10 viewers. Compare-mode viewers are always freshly created (keeping N side-by-side viewers alive would multiply memory cost). Instead a lightweight state snapshot {camera, selectedNodeId, activeExtensions, colorBy} is saved to state.compareStateCache on every statechange event. On re-entry each new viewer is seeded from the snapshot: selectNode+animate if a node was selected, setState({camera}) otherwise. README section 12 documents the memory budget and trade-off rationale. Co-Authored-By: Claude Sonnet 4.6 --- .../qualcomm/debugger/observatory/README.md | 71 ++++++++++++++ .../observatory/templates/js/00_state.js | 2 + .../observatory/templates/js/01_utils.js | 35 ++++++- .../observatory/templates/js/03_blocks.js | 93 +++++++++++++++---- backends/qualcomm/utils/fx_viewer/exporter.py | 1 - 5 files changed, 181 insertions(+), 21 deletions(-) diff --git a/backends/qualcomm/debugger/observatory/README.md b/backends/qualcomm/debugger/observatory/README.md index 73c45765a6e..d5379bdc0b0 100644 --- a/backends/qualcomm/debugger/observatory/README.md +++ b/backends/qualcomm/debugger/observatory/README.md @@ -901,3 +901,74 @@ GraphView( See: 1. `examples/OBSERVATORY_UI_TESTCASES.md` + +## 12. Performance Notes — Viewer Lifecycle and Caching + +### 12.1 Single-record viewers: live DOM cache with LRU eviction + +Single-record graph blocks use a **live viewer cache** keyed by +`(recordIndex, lensName, blockId)`. + +On first visit a `FXGraphViewer` instance is created and its DOM wrapper is stored in +`state.viewerCache`. On return visit the existing wrapper is re-appended to the new +container via `appendChild` (moves the DOM node, no clone). The viewer's full state — +camera pan/zoom, selected node, active extension layers, colorBy, search query — is +preserved exactly as left. No `init()`, no `computeActiveGraph()`, no re-layout. + +On navigate away `destroyGraphRuntime()` detaches the wrapper from the DOM but does +**not** call `viewer.destroy()`. The viewer stays alive in memory. + +**LRU eviction**: the cache holds at most `MAX_CACHED_VIEWERS = 10` live viewers. When +the cap is reached, the least-recently-accessed viewer is destroyed and removed. For a +typical report with 5 records × 2 graph blocks = 10 viewers, nothing is ever evicted. + +**Memory budget per cached viewer** (approximate, 1200×640 viewport, dpr=2): + +| Component | Size | +|---|---| +| Canvas pixel buffer | ~12 MB | +| Node/edge JS objects (3600 nodes) | ~2 MB | +| DOM elements | ~0.1 MB | +| **Total** | **~14 MB** | + +10 cached viewers ≈ 140 MB. For reports with more records, the LRU cap bounds memory use. + +### 12.2 Compare-mode viewers: state-snapshot cache + +Compare-mode viewers are **always freshly created** on each visit. Keeping multiple +side-by-side viewers alive simultaneously would multiply the memory cost above by the +number of panes, with limited benefit since compare views are typically visited briefly. + +Instead, a lightweight **state snapshot** is saved to `state.compareStateCache` whenever +any compare viewer's state changes. The snapshot stores: +`{ camera: {x,y,k}, selectedNodeId, activeExtensions, colorBy }` — a few bytes per block. + +Cache key: `"compare::"` — intentionally **not** keyed by pool +composition or graphRef. Consequences: + +- Adding or removing records from the compare pool restores the same camera and layers. +- Switching from single-record mode back to compare mode restores the compare state. +- All viewers in the same compare block share one snapshot (they are synced via + `FXGraphCompare` anyway, so their states converge). + +On re-entry, each new viewer is seeded from the snapshot in priority order: +1. `selectedNodeId` present and node exists in graph → `selectNode` + animate. +2. `camera` present, no node selection → `setState({ camera })` to restore pan/zoom. +3. No snapshot → `init()` runs normally (zoom-to-fit or first-node centering). + +Layers (`activeExtensions`, `colorBy`) are always restored from the snapshot when +available, regardless of which priority path is taken for camera/selection. + +### 12.3 Section collapse state + +Section open/close state is persisted to `localStorage` under key +`graphCollectorViewPrefs` as `"${lensName}:${blockId}" → bool`. This is independent of +the viewer cache and survives full page reloads. + +### 12.4 Trade-off summary + +| Scenario | Approach | Memory | Switch-back latency | +|---|---|---|---| +| Single-record graph block | Live DOM cache + LRU | ~14 MB/viewer, cap 10 | ~0 ms (rAF resize only) | +| Compare graph block | State-snapshot + fresh create | ~bytes/block | ~50–200 ms (create + init) | +| Section collapse | localStorage | 0 | 0 | diff --git a/backends/qualcomm/debugger/observatory/templates/js/00_state.js b/backends/qualcomm/debugger/observatory/templates/js/00_state.js index d609471f870..f424b2298d7 100644 --- a/backends/qualcomm/debugger/observatory/templates/js/00_state.js +++ b/backends/qualcomm/debugger/observatory/templates/js/00_state.js @@ -10,6 +10,8 @@ selectedIndices: new Set(), mountedViewers: [], mountedCompares: [], + viewerCache: new Map(), + compareStateCache: new Map(), }; OBS.app = document.getElementById('app'); diff --git a/backends/qualcomm/debugger/observatory/templates/js/01_utils.js b/backends/qualcomm/debugger/observatory/templates/js/01_utils.js index f70a8c76c6b..edb65f84e15 100644 --- a/backends/qualcomm/debugger/observatory/templates/js/01_utils.js +++ b/backends/qualcomm/debugger/observatory/templates/js/01_utils.js @@ -90,6 +90,31 @@ }; } + const MAX_CACHED_VIEWERS = 10; + + function buildViewerCacheKey(mode, recordIndex, lensName, blockId) { + if (mode === 'single') { + return `single:${recordIndex}:${lensName}:${blockId}`; + } + return `compare:${lensName}:${blockId}`; + } + + function evictViewerCache() { + if (state.viewerCache.size < MAX_CACHED_VIEWERS) return; + let oldestKey = null, oldestTime = Infinity; + for (const [k, entry] of state.viewerCache) { + if (entry.lastAccessed < oldestTime) { + oldestTime = entry.lastAccessed; + oldestKey = k; + } + } + if (oldestKey) { + const entry = state.viewerCache.get(oldestKey); + try { entry.viewer.destroy(); } catch (_) {} + state.viewerCache.delete(oldestKey); + } + } + function destroyGraphRuntime() { for (const compare of state.mountedCompares) { try { @@ -99,9 +124,10 @@ state.mountedCompares = []; for (const viewer of state.mountedViewers) { - try { - if (viewer && typeof viewer.destroy === 'function') viewer.destroy(); - } catch (_e) {} + if (!viewer) continue; + if (viewer.wrapper && viewer.wrapper.parentNode) { + viewer.wrapper.parentNode.removeChild(viewer.wrapper); + } } state.mountedViewers = []; } @@ -116,5 +142,8 @@ toArraySet, buildViewerPayload, destroyGraphRuntime, + buildViewerCacheKey, + evictViewerCache, + MAX_CACHED_VIEWERS, }; })(); diff --git a/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js b/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js index ac7c562c060..000267611fe 100644 --- a/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js +++ b/backends/qualcomm/debugger/observatory/templates/js/03_blocks.js @@ -10,6 +10,8 @@ toArraySet, buildViewerPayload, destroyGraphRuntime, + buildViewerCacheKey, + evictViewerCache, } = OBS.utils; function createSection(title, storageKey, collapsible) { @@ -94,7 +96,21 @@ ); } - function mountGraphViewer(root, graphRecord, viewerOptions, fallbackGraphRef) { + function mountGraphViewer(root, graphRecord, viewerOptions, fallbackGraphRef, cacheKey) { + // Cache hit: reattach existing live viewer + if (cacheKey && state.viewerCache.has(cacheKey)) { + const entry = state.viewerCache.get(cacheKey); + entry.lastAccessed = Date.now(); + root.appendChild(entry.wrapper); + requestAnimationFrame(() => { + try { entry.viewer.canvasRenderer.resize(); } catch (_) {} + try { entry.viewer.renderAll(); } catch (_) {} + }); + state.mountedViewers.push(entry.viewer); + return entry.viewer; + } + + // Cache miss: create new viewer const ViewerCtor = resolveViewerCtor(); const graphRef = resolveGraphRef(graphRecord, fallbackGraphRef); @@ -117,22 +133,26 @@ payload, mount: { root }, layout: { preset }, - state: { - activeExtensions: defaultLayers, - colorBy: defaultColorBy, - }, + state: { activeExtensions: defaultLayers, colorBy: defaultColorBy }, }); - viewer.init(); + + // FIX: defer init() until after browser layout pass so getBoundingClientRect() is valid + requestAnimationFrame(() => viewer.init()); if ((viewerOptions || {}).sidebar_mode === 'hidden' && typeof viewer.setLayout === 'function') { - try { - viewer.setLayout({ panels: { sidebar: { visible: false } } }); - } catch (_e) {} + try { viewer.setLayout({ panels: { sidebar: { visible: false } } }); } catch (_e) {} } if ((viewerOptions || {}).minimap_mode === 'off' && typeof viewer.setUIVisibility === 'function') { - try { - viewer.setUIVisibility({ minimapToggle: false }); - } catch (_e) {} + try { viewer.setUIVisibility({ minimapToggle: false }); } catch (_e) {} + } + + if (cacheKey) { + evictViewerCache(); + state.viewerCache.set(cacheKey, { + viewer, + wrapper: viewer.wrapper, + lastAccessed: Date.now(), + }); } state.mountedViewers.push(viewer); @@ -180,7 +200,11 @@ graphRoot.style.overflow = 'hidden'; content.appendChild(graphRoot); const fallbackGraphRef = (context && context.record && context.record.name) || ''; - mountGraphViewer(graphRoot, block.record || {}, (block.record && block.record.viewer_options) || {}, fallbackGraphRef); + const recordIndex = (context && context.index !== undefined) ? context.index : -1; + const cacheKey = (recordIndex >= 0) + ? buildViewerCacheKey('single', recordIndex, lensName, block.id || block.type) + : null; + mountGraphViewer(graphRoot, block.record || {}, (block.record && block.record.viewer_options) || {}, fallbackGraphRef, cacheKey); } else { content.innerHTML = `
    Unsupported block type: ${escapeHtml(block.type || '')}
    `; } @@ -329,7 +353,7 @@ } } - function renderGraphCompare(content, entries, compareSpec) { + function renderGraphCompare(content, entries, compareSpec, lensName, blockId) { const maxParallel = Math.max(1, Number(compareSpec.max_parallel || 2)); const syncEnabledByDefault = compareSpec.sync_toggle !== false; const selected = entries.slice(0, maxParallel); @@ -349,6 +373,9 @@ split.className = 'split-view'; content.appendChild(split); + const snapKey = buildViewerCacheKey('compare', null, lensName, blockId); + const snap = state.compareStateCache.get(snapKey); + const viewers = []; for (const entry of selected) { const pane = document.createElement('div'); @@ -372,8 +399,40 @@ const options = Object.assign({}, (entry.block && entry.block.record && entry.block.record.viewer_options) || {}, compareSpec.viewer_options_compare || {}); const fallbackGraphRef = (entry.record && entry.record.name) || ''; - const viewer = mountGraphViewer(root, entry.block.record || {}, options, fallbackGraphRef); - if (viewer) viewers.push(viewer); + const viewer = mountGraphViewer(root, entry.block.record || {}, options, fallbackGraphRef, null); + + if (viewer) { + requestAnimationFrame(() => { + if (snap) { + if (Array.isArray(snap.activeExtensions) && typeof viewer.setLayers === 'function') { + try { viewer.setLayers(snap.activeExtensions); } catch (_) {} + } + if (snap.colorBy && typeof viewer.setColorBy === 'function') { + try { viewer.setColorBy(snap.colorBy); } catch (_) {} + } + if (snap.selectedNodeId && viewer.store && viewer.store.activeNodeMap && viewer.store.activeNodeMap.has(snap.selectedNodeId)) { + try { viewer.selectNode(snap.selectedNodeId, { animate: true }); } catch (_) {} + } else if (snap.camera && typeof viewer.setState === 'function') { + try { viewer.setState({ camera: snap.camera }); } catch (_) {} + } + } + + if (typeof viewer.on === 'function') { + viewer.on('statechange', () => { + try { + const s = viewer.getState(); + state.compareStateCache.set(snapKey, { + camera: s.camera, + selectedNodeId: s.selectedNodeId || null, + activeExtensions: s.activeExtensions || [], + colorBy: s.colorBy || 'base', + }); + } catch (_) {} + }); + } + }); + viewers.push(viewer); + } split.appendChild(pane); } @@ -458,7 +517,7 @@ } else if (sample.type === 'html' && mode === 'auto') { renderHtmlCompare(content, entries); } else if (sample.type === 'graph' && mode === 'auto') { - renderGraphCompare(content, entries, compareSpec); + renderGraphCompare(content, entries, compareSpec, lensName, sample.id); } else if (mode === 'custom') { renderCustomCompare(content, entries, compareSpec, sample, lensName, sample.id); } else { diff --git a/backends/qualcomm/utils/fx_viewer/exporter.py b/backends/qualcomm/utils/fx_viewer/exporter.py index bb9f0b12d1e..164368c26ea 100644 --- a/backends/qualcomm/utils/fx_viewer/exporter.py +++ b/backends/qualcomm/utils/fx_viewer/exporter.py @@ -279,7 +279,6 @@ def _load_viewer_js_bundle() -> str: path = os.path.join(template_dir, filename) with open(path, "r") as f: chunks.append(f"\n// ---- {filename} ----\n") - chunks.append(f'console.log("Successfully Loaded {path}")') chunks.append(f.read()) return "\n".join(chunks) From 51f3dcbc4e4f513608a75ae9fa9114b18578a7d7 Mon Sep 17 00:00:00 2001 From: boyuc Date: Mon, 23 Mar 2026 22:26:00 +0800 Subject: [PATCH 20/65] observatory: add zero-config CLI runner with pipeline graph and accuracy lenses Introduces a `python -m executorch.backends.qualcomm.debugger.observatory.cli` runner that wraps any standard Qualcomm example script (e.g. swin_v2_t.py) to automatically enable full Observatory debugging with no script modifications. Key changes: PipelineGraphCollectorLens (new): - Absorbs ETRecordAutoCollector from the deleted auto_collect.py, moving all monkey-patching out of the Observatory core framework and into a lens. - Patches framework-level functions (torch.export.export, prepare_pt2e, convert_pt2e, to_edge_transform_and_lower, ETRecord.add_*) to auto-collect graph snapshots at each compilation stage: Exported Float, Annotated Model, Calibrated Model, Quantized Model, Edge, Transformed Edge, ETRecord records. - Forces generate_etrecord=True in to_edge_transform_and_lower to ensure ETRecord collection fires automatically. - Framework-level patches work for all backends (QNN, XNNPack, CoreML, etc.). AccuracyLens (new): - Ports AccuracyEvaluationLens from legacy debugging_utils to new Observatory interfaces (ViewList/TableBlock). - Adds MaskedTokenAccuracy metric and MLMEvaluator for masked language model scripts (bert, roberta, distilbert, albert, eurobert). - Auto-patches get_imagenet_dataset and get_masked_language_model_dataset to capture targets, covering 24 of ~30 standard oss_scripts. - Auto-detects task type (classification vs MLM), post_process function, and default metrics from model output format. CLI runner (new): - cli.py + __main__.py: parse observatory flags, register lenses, wrap target script in Observatory.enable_context(), run via runpy.run_path(), generate HTML + JSON report to {artifact}/observatory_report.{html,json}. - Flags: --no-accuracy, --no-report, --report-title. observatory.py: - Remove ETRecordAutoCollector.install/uninstall calls; patching is now delegated to PipelineGraphCollectorLens when registered. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../qualcomm/debugger/observatory/__main__.py | 9 + .../debugger/observatory/auto_collect.py | 104 --- backends/qualcomm/debugger/observatory/cli.py | 116 ++++ .../examples/demo_etrecord_auto_collect.py | 4 + .../debugger/observatory/lenses/accuracy.py | 599 ++++++++++++++++++ .../lenses/pipeline_graph_collector.py | 329 ++++++++++ .../debugger/observatory/observatory.py | 4 - 7 files changed, 1057 insertions(+), 108 deletions(-) create mode 100644 backends/qualcomm/debugger/observatory/__main__.py delete mode 100644 backends/qualcomm/debugger/observatory/auto_collect.py create mode 100644 backends/qualcomm/debugger/observatory/cli.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/accuracy.py create mode 100644 backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py diff --git a/backends/qualcomm/debugger/observatory/__main__.py b/backends/qualcomm/debugger/observatory/__main__.py new file mode 100644 index 00000000000..e69e40ec80e --- /dev/null +++ b/backends/qualcomm/debugger/observatory/__main__.py @@ -0,0 +1,9 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +from .cli import main + +main() diff --git a/backends/qualcomm/debugger/observatory/auto_collect.py b/backends/qualcomm/debugger/observatory/auto_collect.py deleted file mode 100644 index 7dc4dc9d844..00000000000 --- a/backends/qualcomm/debugger/observatory/auto_collect.py +++ /dev/null @@ -1,104 +0,0 @@ -# Copyright (c) Qualcomm Innovation Center, Inc. -# All rights reserved -# -# This source code is licensed under the BSD-style license found in the -# LICENSE file in the root directory of this source tree. - -from __future__ import annotations - -import logging -from typing import Any, Callable, Dict, Optional - - -class ETRecordAutoCollector: - """Monkey-patch ETRecord APIs to auto-collect graph observations.""" - - _installed: bool = False - _originals: Dict[str, Callable[..., Any]] = {} - - @classmethod - def install(cls, collect_fn: Callable[[str, Any], None]) -> None: - if cls._installed: - return - - try: - from executorch.devtools.etrecord._etrecord import ETRecord - except Exception as exc: - logging.warning("[Observatory] Failed to import ETRecord for auto-collect: %s", exc) - return - - def _safe_collect(name: str, artifact: Any) -> None: - try: - collect_fn(name, artifact) - except Exception as exc: - logging.debug("[Observatory] Auto-collect skipped (%s): %s", name, exc) - - def _wrap_add_exported_program(original): - def wrapped(self, exported_program): - result = original(self, exported_program) - if exported_program is None: - return result - if isinstance(exported_program, dict): - for method_name, program in exported_program.items(): - _safe_collect(f"ETRecord Exported/{method_name}", program) - else: - _safe_collect("ETRecord Exported/forward", exported_program) - return result - - return wrapped - - def _wrap_add_edge_dialect_program(original): - def wrapped(self, edge_dialect_program): - result = original(self, edge_dialect_program) - processed = getattr(self, "edge_dialect_program", None) - if isinstance(processed, dict): - for method_name, program in processed.items(): - _safe_collect(f"ETRecord Edge/{method_name}", program) - elif processed is not None: - _safe_collect("ETRecord Edge/forward", processed) - return result - - return wrapped - - def _wrap_add_extra_export_modules(original): - def wrapped(self, extra_recorded_export_modules): - result = original(self, extra_recorded_export_modules) - graph_map = getattr(self, "graph_map", {}) or {} - for module_name, program in graph_map.items(): - _safe_collect(f"ETRecord Extra/{module_name}", program) - return result - - return wrapped - - patches = { - "add_exported_program": _wrap_add_exported_program, - "add_edge_dialect_program": _wrap_add_edge_dialect_program, - "add_extra_export_modules": _wrap_add_extra_export_modules, - } - - for method_name, wrap_builder in patches.items(): - original = getattr(ETRecord, method_name, None) - if original is None: - continue - cls._originals[method_name] = original - setattr(ETRecord, method_name, wrap_builder(original)) - - cls._installed = True - - @classmethod - def uninstall(cls) -> None: - if not cls._installed: - return - - try: - from executorch.devtools.etrecord._etrecord import ETRecord - except Exception: - cls._originals.clear() - cls._installed = False - return - - for method_name, original in cls._originals.items(): - setattr(ETRecord, method_name, original) - - cls._originals.clear() - cls._installed = False diff --git a/backends/qualcomm/debugger/observatory/cli.py b/backends/qualcomm/debugger/observatory/cli.py new file mode 100644 index 00000000000..33bea66e8a4 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/cli.py @@ -0,0 +1,116 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Observatory CLI runner — zero-config debugging for ExecuTorch scripts. + +Usage: + python -m executorch.backends.qualcomm.debugger.observatory.cli \\ + examples/qualcomm/oss_scripts/swin_v2_t.py \\ + --model SM8650 -b ./build-android -c -d imagenet-mini/val -a ./swin_v2_t + +The runner wraps the target script in an Observatory context, automatically +collecting graph snapshots and accuracy metrics at each compilation stage. +""" + +from __future__ import annotations + +import logging +import os +import runpy +import sys + +logging.basicConfig(level=logging.INFO, format="%(message)s") + + +def _parse_observatory_args(): + """Extract observatory-specific flags from sys.argv, return (obs_args, script_argv).""" + + obs_flags = { + "--no-accuracy": False, + "--no-report": False, + "--report-title": None, + } + script_path = None + script_argv = [] + i = 1 + while i < len(sys.argv): + arg = sys.argv[i] + if arg == "--no-accuracy": + obs_flags["--no-accuracy"] = True + elif arg == "--no-report": + obs_flags["--no-report"] = True + elif arg == "--report-title" and i + 1 < len(sys.argv): + i += 1 + obs_flags["--report-title"] = sys.argv[i] + elif script_path is None and not arg.startswith("--"): + script_path = arg + else: + script_argv.append(arg) + i += 1 + + if script_path is None: + print("Usage: python -m executorch.backends.qualcomm.debugger.observatory.cli " + "[--no-accuracy] [--no-report] [--report-title TITLE] SCRIPT [SCRIPT_ARGS...]") + sys.exit(1) + + return obs_flags, script_path, script_argv + + +def main(): + obs_flags, script_path, script_argv = _parse_observatory_args() + + from .observatory import Observatory + from .lenses.pipeline_graph_collector import PipelineGraphCollectorLens + + Observatory.clear() + Observatory.register_lens(PipelineGraphCollectorLens) + + if not obs_flags["--no-accuracy"]: + from .lenses.accuracy import AccuracyLens + + Observatory.register_lens(AccuracyLens) + + sys.argv = [script_path] + script_argv + + config: dict = {} + artifact_path = "." + + # Try to extract artifact path from script args for report output + for j, arg in enumerate(script_argv): + if arg in ("-a", "--artifact") and j + 1 < len(script_argv): + artifact_path = script_argv[j + 1] + break + + title = obs_flags["--report-title"] or f"Observatory: {os.path.basename(script_path)}" + + try: + with Observatory.enable_context(config=config): + runpy.run_path(script_path, run_name="__main__") + except SystemExit: + pass + except Exception as exc: + logging.error("[Observatory CLI] Script raised: %s", exc) + finally: + if not obs_flags["--no-report"]: + os.makedirs(artifact_path, exist_ok=True) + report_html = os.path.join(artifact_path, "observatory_report.html") + report_json = os.path.join(artifact_path, "observatory_report.json") + Observatory.export_html_report(report_html, title=title, config=config) + Observatory.export_json(report_json) + collected = Observatory.list_collected() + if collected: + logging.info( + "[Observatory CLI] Report: %s (%d records: %s)", + report_html, + len(collected), + ", ".join(collected), + ) + else: + logging.warning("[Observatory CLI] No records collected") + + +if __name__ == "__main__": + main() diff --git a/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py b/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py index e26d6b8b208..be310e110d9 100644 --- a/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py +++ b/backends/qualcomm/debugger/observatory/examples/demo_etrecord_auto_collect.py @@ -29,6 +29,9 @@ import torch from executorch.backends.qualcomm.debugger.observatory import Observatory +from executorch.backends.qualcomm.debugger.observatory.lenses.pipeline_graph_collector import ( + PipelineGraphCollectorLens, +) from executorch.devtools.etrecord import ETRecord @@ -58,6 +61,7 @@ def main() -> None: exported_program = torch.export.export(model, sample_inputs, strict=False) Observatory.clear() + Observatory.register_lens(PipelineGraphCollectorLens) with Observatory.enable_context(): # No manual Observatory.collect call here. diff --git a/backends/qualcomm/debugger/observatory/lenses/accuracy.py b/backends/qualcomm/debugger/observatory/lenses/accuracy.py new file mode 100644 index 00000000000..be51fd26d9e --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/accuracy.py @@ -0,0 +1,599 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Accuracy Evaluation Lens — auto-captures datasets and evaluates model accuracy. + +This lens patches dataset loaders and build functions to transparently capture +evaluation data, then runs accuracy metrics on collected model artifacts. + +Patches installed on session start: + - get_imagenet_dataset: captures (inputs, targets) for ImageNet classification + - get_masked_language_model_dataset: captures (inputs, targets) for MLM tasks + - build_executorch_binary: captures float model + dataset references + +Auto-configuration: + - Detects task type (classification vs MLM) from target format + - Auto-selects metrics (TopK, PSNR, CosineSimilarity, MaskedTokenAccuracy) + - Auto-detects post_process function from model output type +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional, Protocol, Union + +import numpy as np +import torch +import torch.nn.functional as F + +from ..interfaces import ( + AnalysisResult, + Frontend, + Lens, + ObservationContext, + RecordAnalysis, + RecordDigest, + TableBlock, + TableRecordSpec, + ViewList, +) + + +# --------------------------------------------------------------------------- +# Data model classes +# --------------------------------------------------------------------------- + + +@dataclass +class PrecomputedOutputs: + """Wrapper for inference results obtained externally (e.g., from device).""" + + outputs: List[torch.Tensor] + + def __post_init__(self): + if isinstance(self.outputs, list) and self.outputs: + if isinstance(self.outputs[0], np.ndarray): + self.outputs = [torch.from_numpy(o) for o in self.outputs] + + +class Metric(Protocol): + def calculate(self, predictions: List[torch.Tensor]) -> float: ... + def name(self) -> str: ... + + +class TopKAccuracy: + def __init__(self, targets: List[Any], k: int = 1): + self.targets = targets + self.k = k + + def name(self) -> str: + return f"top_{self.k}" + + def calculate(self, predictions: List[torch.Tensor]) -> float: + correct = 0 + total = len(predictions) + if total == 0: + return 0.0 + for pred, target in zip(predictions, self.targets): + if not isinstance(pred, torch.Tensor): + pred = torch.tensor(pred) + if not isinstance(target, torch.Tensor): + target = torch.tensor(target) + if pred.dim() == 2: + pred = pred.squeeze(0) + _, indices = pred.topk(self.k) + if target.view(-1) in indices: + correct += 1 + return (correct / total) * 100.0 + + +class CosineSimilarity: + def __init__(self, golden_outputs: List[torch.Tensor]): + self.golden = golden_outputs + + def name(self) -> str: + return "cosine_sim" + + def calculate(self, predictions: List[torch.Tensor]) -> float: + if not self.golden or len(predictions) != len(self.golden): + return 0.0 + sims = [] + for p, g in zip(predictions, self.golden): + p_flat = p.flatten().float() + g_flat = g.flatten().float() + sims.append( + F.cosine_similarity(p_flat.unsqueeze(0), g_flat.unsqueeze(0)).item() + ) + return float(np.mean(sims)) + + +class PSNR: + def __init__(self, golden_outputs: List[torch.Tensor]): + self.golden = golden_outputs + self.max_val = ( + max(torch.max(g).item() for g in golden_outputs) if golden_outputs else 1.0 + ) + + def name(self) -> str: + return "psnr" + + def calculate(self, predictions: List[torch.Tensor]) -> float: + if not self.golden or len(predictions) != len(self.golden): + return 0.0 + psnrs = [] + for p, g in zip(predictions, self.golden): + mse = F.mse_loss(p.float(), g.float()) + if mse == 0: + psnrs.append(float("inf")) + else: + psnrs.append( + 20 + * torch.log10( + torch.tensor(self.max_val) / torch.sqrt(mse) + ).item() + ) + valid = [x for x in psnrs if x != float("inf")] + return float(np.mean(valid)) if valid else 100.0 + + +class MaskedTokenAccuracy: + """Token-level accuracy for MLM models, filtering by ignore_index (-100).""" + + def __init__(self, targets: List[torch.Tensor], ignore_index: int = -100): + self.targets = targets + self.ignore_index = ignore_index + + def name(self) -> str: + return "masked_token_accuracy" + + def calculate(self, predictions: List[torch.Tensor]) -> float: + correct, total = 0, 0 + for pred, target in zip(predictions, self.targets): + if not isinstance(target, torch.Tensor): + target = torch.tensor(target) + indices = [ + i + for i, t in enumerate(target.view(-1)) + if t.item() != self.ignore_index + ] + if not indices: + continue + if pred.dim() >= 2: + pred_tokens = pred.view(-1, pred.shape[-1]).argmax(dim=-1) + else: + pred_tokens = pred.view(-1) + for i in indices: + if i < len(pred_tokens) and pred_tokens[i].item() == target.view(-1)[ + i + ].item(): + correct += 1 + total += len(indices) + return (correct / total) * 100.0 if total > 0 else 0.0 + + +# --------------------------------------------------------------------------- +# Evaluators +# --------------------------------------------------------------------------- + + +class Evaluator: + def __init__( + self, + dataset: List[Any], + metrics: List[Any], + post_process: Optional[Callable] = None, + ): + self.dataset = dataset + self.metrics = metrics + self.post_process = post_process or (lambda x: x) + + def evaluate(self, model: Any) -> Dict[str, float]: + predictions = self.run_inference(model, self.dataset) + results = {} + for metric in self.metrics: + try: + results[metric.name()] = metric.calculate(predictions) + except Exception as e: + logging.error("Metric %s failed: %s", metric.name(), e) + results[metric.name()] = f"error: {e}" + return results + + def run_inference(self, model: Any, dataset: List[Any]) -> List[torch.Tensor]: + raise NotImplementedError + + +class StandardEvaluator(Evaluator): + """Standard evaluator for classification and regression models.""" + + def run_inference(self, model: Any, dataset: List[Any]) -> List[torch.Tensor]: + if isinstance(model, PrecomputedOutputs): + return model.outputs + predictions = [] + is_ep = hasattr(model, "module") and callable(model.module) + executable = model.module() if is_ep else model + for inputs in dataset: + args = inputs if isinstance(inputs, (tuple, list)) else (inputs,) + raw_out = executable(*args) + out = self.post_process(raw_out) + if isinstance(out, torch.Tensor): + out = out.detach().cpu() + predictions.append(out) + return predictions + + +class MLMEvaluator(Evaluator): + """Evaluator for masked language models with -100 masking.""" + + def run_inference(self, model: Any, dataset: List[Any]) -> List[torch.Tensor]: + if isinstance(model, PrecomputedOutputs): + return model.outputs + predictions = [] + is_ep = hasattr(model, "module") and callable(model.module) + executable = model.module() if is_ep else model + for inputs in dataset: + args = inputs if isinstance(inputs, (tuple, list)) else (inputs,) + out = executable(*args) + logits = out.logits if hasattr(out, "logits") else out + if isinstance(logits, torch.Tensor): + logits = logits.detach().cpu() + predictions.append(logits) + return predictions + + +# --------------------------------------------------------------------------- +# AccuracyLens +# --------------------------------------------------------------------------- + + +class AccuracyLens(Lens): + """Evaluates model accuracy at each collected pipeline stage.""" + + _installed: bool = False + _originals: Dict[str, Any] = {} + + _captured_model: Any = None + _captured_dataset: Optional[List[Any]] = None + _captured_targets: Optional[List[Any]] = None + _golden_outputs: Optional[List[torch.Tensor]] = None + _post_process: Optional[Callable] = None + _evaluator: Optional[Evaluator] = None + _task_type: Optional[str] = None # "classification", "mlm", or None + + @classmethod + def get_name(cls) -> str: + return "accuracy" + + @classmethod + def on_session_start(cls, context: ObservationContext) -> None: + if cls._installed: + return + cls._install_dataset_patches() + cls._install_build_binary_patch() + cls._installed = True + + @classmethod + def on_session_end(cls, context: ObservationContext) -> None: + cls._uninstall_all() + cls._clear_state() + + @classmethod + def clear(cls) -> None: + cls._uninstall_all() + cls._clear_state() + + @classmethod + def _clear_state(cls) -> None: + cls._captured_model = None + cls._captured_dataset = None + cls._captured_targets = None + cls._golden_outputs = None + cls._post_process = None + cls._evaluator = None + cls._task_type = None + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + acc_config = context.config.get("accuracy", {}) + evaluator = acc_config.get("evaluator") or cls._evaluator + if not evaluator: + return None + + if not isinstance( + artifact, + (torch.nn.Module, torch.fx.GraphModule, torch.export.ExportedProgram), + ): + return None + + try: + metrics = evaluator.evaluate(artifact) + return { + k: round(v, 4) if isinstance(v, float) else v + for k, v in metrics.items() + } + except Exception as e: + logging.error("[AccuracyLens] Evaluation failed: %s", e) + return {"error_message": str(e)} + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return observation + + @staticmethod + def analyze( + records: List[RecordDigest], config: Dict[str, Any] + ) -> AnalysisResult: + result = AnalysisResult() + for i, record in enumerate(records): + digest = record.data.get("accuracy") + if digest is None: + continue + analysis = RecordAnalysis() + if i > 0: + prev = records[i - 1].data.get("accuracy", {}) + for key in digest: + if ( + isinstance(digest.get(key), (int, float)) + and isinstance(prev.get(key), (int, float)) + ): + analysis.data[f"{key}_diff"] = round( + digest[key] - prev[key], 4 + ) + result.per_record_data[record.name] = analysis + return result + + @staticmethod + def get_frontend_spec() -> Frontend: + return _AccuracyFrontend() + + # ------------------------------------------------------------------ + # Auto-configuration helpers + # ------------------------------------------------------------------ + + @classmethod + def _detect_task_type(cls, targets: List[Any]) -> str: + if not targets: + return "unknown" + sample = targets[0] + if isinstance(sample, torch.Tensor): + if sample.dim() >= 1 and (sample == -100).any(): + return "mlm" + return "classification" + + @classmethod + def _auto_detect_post_process(cls, model: Any, dataset: List[Any]) -> Callable: + try: + sample = dataset[0] + args = sample if isinstance(sample, (tuple, list)) else (sample,) + with torch.no_grad(): + out = model(*args) + if isinstance(out, torch.Tensor): + return lambda x: x + if hasattr(out, "logits"): + return lambda x: x.logits + if isinstance(out, tuple): + return lambda x: x[0] + except Exception as e: + logging.debug("[AccuracyLens] post_process auto-detect failed: %s", e) + return lambda x: x + + @classmethod + def _compute_golden_outputs( + cls, model: Any, dataset: List[Any], post_process: Callable + ) -> List[torch.Tensor]: + golden = [] + with torch.no_grad(): + for inputs in dataset: + args = inputs if isinstance(inputs, (tuple, list)) else (inputs,) + out = model(*args) + processed = post_process(out) + if isinstance(processed, torch.Tensor): + processed = processed.detach().cpu() + golden.append(processed) + return golden + + @classmethod + def _build_default_evaluator(cls) -> Optional[Evaluator]: + if cls._captured_dataset is None: + return None + + dataset = cls._captured_dataset + targets = cls._captured_targets + golden = cls._golden_outputs + task_type = cls._task_type + + metrics: List[Any] = [] + if golden: + metrics.extend([PSNR(golden), CosineSimilarity(golden)]) + + if task_type == "classification" and targets: + metrics.extend([TopKAccuracy(targets, k=1), TopKAccuracy(targets, k=5)]) + return StandardEvaluator( + dataset=dataset, metrics=metrics, post_process=cls._post_process + ) + elif task_type == "mlm" and targets: + metrics.append(MaskedTokenAccuracy(targets)) + return MLMEvaluator( + dataset=dataset, metrics=metrics, post_process=cls._post_process + ) + elif metrics: + return StandardEvaluator( + dataset=dataset, metrics=metrics, post_process=cls._post_process + ) + return None + + # ------------------------------------------------------------------ + # Patches + # ------------------------------------------------------------------ + + @classmethod + def _install_dataset_patches(cls) -> None: + try: + import executorch.examples.qualcomm.utils as utils_module + + # get_imagenet_dataset + if hasattr(utils_module, "get_imagenet_dataset"): + original = utils_module.get_imagenet_dataset + cls._originals["get_imagenet_dataset"] = original + + def patched_imagenet(*args, **kwargs): + inputs, targets = original(*args, **kwargs) + cls._captured_targets = targets + logging.info( + "[AccuracyLens] Captured ImageNet targets (%d samples)", + len(targets), + ) + return inputs, targets + + utils_module.get_imagenet_dataset = patched_imagenet + logging.info("[AccuracyLens] Installed patch: get_imagenet_dataset") + + # get_masked_language_model_dataset + if hasattr(utils_module, "get_masked_language_model_dataset"): + original_mlm = utils_module.get_masked_language_model_dataset + cls._originals["get_masked_language_model_dataset"] = original_mlm + + def patched_mlm(*args, **kwargs): + inputs, targets = original_mlm(*args, **kwargs) + cls._captured_targets = targets + logging.info( + "[AccuracyLens] Captured MLM targets (%d samples)", + len(targets), + ) + return inputs, targets + + utils_module.get_masked_language_model_dataset = patched_mlm + logging.info( + "[AccuracyLens] Installed patch: get_masked_language_model_dataset" + ) + except ImportError: + logging.debug( + "[AccuracyLens] qualcomm utils not available, skipping dataset patches" + ) + + @classmethod + def _install_build_binary_patch(cls) -> None: + try: + import executorch.examples.qualcomm.utils as utils_module + + if not hasattr(utils_module, "build_executorch_binary"): + return + + original = utils_module.build_executorch_binary + cls._originals["build_executorch_binary"] = original + + def patched_build(model, inputs, soc_model, file_name, dataset, **kwargs): + cls._captured_model = model + if cls._captured_dataset is None: + cls._captured_dataset = ( + dataset if isinstance(dataset, list) else None + ) + + result = original(model, inputs, soc_model, file_name, dataset, **kwargs) + + # Auto-configure evaluator after build completes + if cls._evaluator is None and cls._captured_dataset is not None: + try: + cls._task_type = cls._detect_task_type( + cls._captured_targets or [] + ) + cls._post_process = cls._auto_detect_post_process( + model, cls._captured_dataset + ) + cls._golden_outputs = cls._compute_golden_outputs( + model, cls._captured_dataset, cls._post_process + ) + cls._evaluator = cls._build_default_evaluator() + if cls._evaluator: + logging.info( + "[AccuracyLens] Auto-configured %s evaluator with %d metrics", + cls._task_type, + len(cls._evaluator.metrics), + ) + except Exception as e: + logging.warning( + "[AccuracyLens] Auto-config failed: %s", e + ) + return result + + utils_module.build_executorch_binary = patched_build + logging.info("[AccuracyLens] Installed patch: build_executorch_binary") + except ImportError: + logging.debug( + "[AccuracyLens] qualcomm utils not available, skipping build patch" + ) + + @classmethod + def _uninstall_all(cls) -> None: + if not cls._installed: + return + try: + import executorch.examples.qualcomm.utils as utils_module + + for key, original in cls._originals.items(): + if hasattr(utils_module, key): + setattr(utils_module, key, original) + except ImportError: + pass + cls._originals.clear() + cls._installed = False + logging.info("[AccuracyLens] Uninstalled all patches") + + +# --------------------------------------------------------------------------- +# Frontend +# --------------------------------------------------------------------------- + + +class _AccuracyFrontend(Frontend): + def record( + self, digest: Any, analysis: Dict[str, Any], context: Dict[str, Any] + ) -> Optional[ViewList]: + if not digest or not isinstance(digest, dict): + return None + data = {} + for k, v in digest.items(): + data[k] = v + record_analysis = analysis.get("record", {}) + for k, v in record_analysis.items(): + if k.endswith("_diff"): + metric = k.replace("_diff", "") + if metric in data: + data[f"{metric} (diff)"] = f"{v:+.4f}" if isinstance(v, float) else v + return ViewList( + blocks=[ + TableBlock( + id="accuracy_table", + title="Accuracy", + record=TableRecordSpec(data=data), + order=20, + ) + ] + ) + + def check_index_diffs( + self, prev_digest: Any, curr_digest: Any, analysis: Dict[str, Any] + ) -> Dict[str, str]: + result = {} + if not prev_digest or not curr_digest: + return result + for key in ["psnr", "top_1", "top_5", "cosine_sim", "masked_token_accuracy"]: + if key in prev_digest and key in curr_digest: + prev_val = prev_digest[key] + curr_val = curr_digest[key] + if isinstance(prev_val, (int, float)) and isinstance( + curr_val, (int, float) + ): + result[key] = f"{curr_val - prev_val:+.2f}" + return result + + def check_badges( + self, digest: Any, analysis: Dict[str, Any] + ) -> List[Dict[str, str]]: + badges = [] + if digest and isinstance(digest, dict) and "error_message" in digest: + badges.append({"label": "ERR", "color": "#d73a49"}) + return badges diff --git a/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py b/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py new file mode 100644 index 00000000000..9d7e486b229 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py @@ -0,0 +1,329 @@ +# Copyright (c) Qualcomm Innovation Center, Inc. +# All rights reserved +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Pipeline Graph Collector Lens — auto-collects graphs at compilation stages. + +This lens installs monkey-patches on framework-level functions to transparently +capture graph artifacts at each stage of the export → quantize → lower pipeline. +All patches are installed on session start and removed on session end. + +Collection points (in pipeline order): + 1. torch.export.export → "Exported Float" (ExportedProgram) + 2. prepare_pt2e → "Annotated Model" (GraphModule with observers) + 3. convert_pt2e (input) → "Calibrated Model" (post-calibration, pre-convert) + 4. convert_pt2e (output) → "Quantized Model" (GraphModule with Q/DQ ops) + 5. to_edge_transform_and_lower → "Edge" / "Transformed Edge" + 6. ETRecord.add_* → "ETRecord Exported/…", "ETRecord Edge/…", etc. + +Patching strategy: + - Framework-level patches (torchao, executorch.exir) work for ALL backends. + - ETRecord patches fire when generate_etrecord=True (forced by the + to_edge_transform_and_lower patch). + - All originals are saved in _originals and restored on session end. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable, Dict, List, Optional + +from ..interfaces import AnalysisResult, Lens, ObservationContext, RecordDigest + + +class PipelineGraphCollectorLens(Lens): + """Unified graph collector — owns all graph-related monkey-patches.""" + + _installed: bool = False + _originals: Dict[str, Any] = {} + _collect_fn: Optional[Callable[[str, Any], None]] = None + + @classmethod + def get_name(cls) -> str: + return "pipeline_graph_collector" + + @classmethod + def on_session_start(cls, context: ObservationContext) -> None: + if cls._installed: + return + + from ..observatory import Observatory + + cls._collect_fn = Observatory.collect + cls._install_export_patch() + cls._install_quantizer_patches() + cls._install_edge_lower_patch() + cls._install_etrecord_patches() + cls._installed = True + + @classmethod + def on_session_end(cls, context: ObservationContext) -> None: + cls._uninstall_all() + + @classmethod + def clear(cls) -> None: + cls._uninstall_all() + + @classmethod + def observe(cls, artifact: Any, context: ObservationContext) -> Any: + return None + + @classmethod + def digest(cls, observation: Any, context: ObservationContext) -> Any: + return None + + @staticmethod + def analyze(records: List[RecordDigest], config: Dict[str, Any]) -> AnalysisResult: + return AnalysisResult() + + # ------------------------------------------------------------------ + # Patch: torch.export.export + # Captures the ExportedProgram produced from the float model. + # ------------------------------------------------------------------ + + @classmethod + def _install_export_patch(cls) -> None: + try: + import torch.export as export_module + + original = export_module.export + cls._originals["torch.export.export"] = original + + def patched_export(*args, **kwargs): + result = original(*args, **kwargs) + try: + cls._collect_fn("Exported Float", result) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] collect skipped (Exported Float): %s", + exc, + ) + return result + + export_module.export = patched_export + logging.info("[PipelineGraphCollector] Installed patch: torch.export.export") + except Exception as exc: + logging.warning( + "[PipelineGraphCollector] Failed to patch torch.export.export: %s", exc + ) + + # ------------------------------------------------------------------ + # Patch: prepare_pt2e, convert_pt2e + # Captures annotated model (post-prepare) and quantized model + # (post-convert). Also captures the calibrated model (convert input). + # ------------------------------------------------------------------ + + @classmethod + def _install_quantizer_patches(cls) -> None: + try: + import torchao.quantization.pt2e.quantize_pt2e as qt_module + + # prepare_pt2e + original_prepare = qt_module.prepare_pt2e + cls._originals["prepare_pt2e"] = original_prepare + + def patched_prepare_pt2e(model, *args, **kwargs): + result = original_prepare(model, *args, **kwargs) + try: + cls._collect_fn("Annotated Model", result) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] collect skipped (Annotated Model): %s", + exc, + ) + return result + + qt_module.prepare_pt2e = patched_prepare_pt2e + logging.info("[PipelineGraphCollector] Installed patch: prepare_pt2e") + + # convert_pt2e — collect both input (calibrated) and output (quantized) + original_convert = qt_module.convert_pt2e + cls._originals["convert_pt2e"] = original_convert + + def patched_convert_pt2e(model, *args, **kwargs): + try: + cls._collect_fn("Calibrated Model", model) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] collect skipped (Calibrated Model): %s", + exc, + ) + result = original_convert(model, *args, **kwargs) + try: + cls._collect_fn("Quantized Model", result) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] collect skipped (Quantized Model): %s", + exc, + ) + return result + + qt_module.convert_pt2e = patched_convert_pt2e + logging.info("[PipelineGraphCollector] Installed patch: convert_pt2e") + except Exception as exc: + logging.warning( + "[PipelineGraphCollector] Failed to patch quantizer APIs: %s", exc + ) + + # ------------------------------------------------------------------ + # Patch: to_edge_transform_and_lower + # Forces generate_etrecord=True and collects edge programs. + # ------------------------------------------------------------------ + + @classmethod + def _install_edge_lower_patch(cls) -> None: + try: + import executorch.exir.program._program as program_module + + original = program_module.to_edge_transform_and_lower + cls._originals["to_edge_transform_and_lower"] = original + + def patched_to_edge_transform_and_lower(*args, **kwargs): + kwargs["generate_etrecord"] = True + result = original(*args, **kwargs) + try: + cls._collect_fn("Edge", result.exported_program()) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] collect skipped (Edge): %s", exc + ) + return result + + program_module.to_edge_transform_and_lower = ( + patched_to_edge_transform_and_lower + ) + logging.info( + "[PipelineGraphCollector] Installed patch: to_edge_transform_and_lower" + ) + except Exception as exc: + logging.warning( + "[PipelineGraphCollector] Failed to patch to_edge_transform_and_lower: %s", + exc, + ) + + # ------------------------------------------------------------------ + # Patch: ETRecord methods + # Auto-collects graph observations when ETRecord APIs are called. + # Absorbed from the former auto_collect.py module. + # ------------------------------------------------------------------ + + @classmethod + def _install_etrecord_patches(cls) -> None: + try: + from executorch.devtools.etrecord._etrecord import ETRecord + except Exception as exc: + logging.warning( + "[PipelineGraphCollector] Failed to import ETRecord: %s", exc + ) + return + + collect = cls._collect_fn + + def _safe_collect(name: str, artifact: Any) -> None: + try: + collect(name, artifact) + except Exception as exc: + logging.debug( + "[PipelineGraphCollector] ETRecord auto-collect skipped (%s): %s", + name, + exc, + ) + + def _wrap_add_exported_program(original): + def wrapped(self, exported_program): + result = original(self, exported_program) + if exported_program is None: + return result + if isinstance(exported_program, dict): + for method_name, program in exported_program.items(): + _safe_collect(f"ETRecord Exported/{method_name}", program) + else: + _safe_collect("ETRecord Exported/forward", exported_program) + return result + + return wrapped + + def _wrap_add_edge_dialect_program(original): + def wrapped(self, edge_dialect_program): + result = original(self, edge_dialect_program) + processed = getattr(self, "edge_dialect_program", None) + if isinstance(processed, dict): + for method_name, program in processed.items(): + _safe_collect(f"ETRecord Edge/{method_name}", program) + elif processed is not None: + _safe_collect("ETRecord Edge/forward", processed) + return result + + return wrapped + + def _wrap_add_extra_export_modules(original): + def wrapped(self, extra_recorded_export_modules): + result = original(self, extra_recorded_export_modules) + graph_map = getattr(self, "graph_map", {}) or {} + for module_name, program in graph_map.items(): + _safe_collect(f"ETRecord Extra/{module_name}", program) + return result + + return wrapped + + patches = { + "add_exported_program": _wrap_add_exported_program, + "add_edge_dialect_program": _wrap_add_edge_dialect_program, + "add_extra_export_modules": _wrap_add_extra_export_modules, + } + + for method_name, wrap_builder in patches.items(): + original = getattr(ETRecord, method_name, None) + if original is None: + continue + cls._originals[f"ETRecord.{method_name}"] = original + setattr(ETRecord, method_name, wrap_builder(original)) + + logging.info("[PipelineGraphCollector] Installed ETRecord patches") + + # ------------------------------------------------------------------ + # Uninstall all patches + # ------------------------------------------------------------------ + + @classmethod + def _uninstall_all(cls) -> None: + if not cls._installed: + return + + for key, original in cls._originals.items(): + try: + if key == "torch.export.export": + import torch.export as export_module + + export_module.export = original + elif key == "prepare_pt2e": + import torchao.quantization.pt2e.quantize_pt2e as qt_module + + qt_module.prepare_pt2e = original + elif key == "convert_pt2e": + import torchao.quantization.pt2e.quantize_pt2e as qt_module + + qt_module.convert_pt2e = original + elif key == "to_edge_transform_and_lower": + import executorch.exir.program._program as program_module + + program_module.to_edge_transform_and_lower = original + elif key.startswith("ETRecord."): + try: + from executorch.devtools.etrecord._etrecord import ETRecord + + method_name = key.split(".", 1)[1] + setattr(ETRecord, method_name, original) + except Exception: + pass + except Exception as exc: + logging.warning( + "[PipelineGraphCollector] Failed to restore %s: %s", key, exc + ) + + cls._originals.clear() + cls._collect_fn = None + cls._installed = False + logging.info("[PipelineGraphCollector] Uninstalled all patches") diff --git a/backends/qualcomm/debugger/observatory/observatory.py b/backends/qualcomm/debugger/observatory/observatory.py index 44665166492..d4761efdf2b 100644 --- a/backends/qualcomm/debugger/observatory/observatory.py +++ b/backends/qualcomm/debugger/observatory/observatory.py @@ -32,7 +32,6 @@ from executorch.backends.qualcomm.utils.fx_viewer.exporter import FXGraphExporter -from .auto_collect import ETRecordAutoCollector from .graph_hub import GraphHub from .interfaces import ( AnalysisResult, @@ -116,7 +115,6 @@ def merge_config_dict(base: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, An hook_ctx = ObservationContext(config=context_config) if is_outermost_start: - ETRecordAutoCollector.install(cls.collect) for lens in cls._lens_registry: try: data = lens.on_session_start(hook_ctx) @@ -138,7 +136,6 @@ def merge_config_dict(base: Dict[str, Any], new: Dict[str, Any]) -> Dict[str, An cls._session_result.end_data[lens.get_name()] = data except Exception as exc: logging.error("[Observatory] Lens %s failed on_session_end: %s", lens, exc) - ETRecordAutoCollector.uninstall() cls._config_stack.pop() @@ -211,7 +208,6 @@ def clear(cls) -> None: cls._records.clear() cls._session_result = SessionResult() - ETRecordAutoCollector.uninstall() for lens in cls._lens_registry: try: From f5eb871e94bf2fa4d39dc585b43a67e40a32ddb8 Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 24 Mar 2026 19:33:07 +0800 Subject: [PATCH 21/65] =?UTF-8?q?observatory(accuracy):=20fix=20timing=20i?= =?UTF-8?q?ssue=20=E2=80=94=20lazy=20evaluator=20setup=20on=20first=20"Exp?= =?UTF-8?q?orted=20Float"=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AccuracyLens was not producing metrics in HTML reports because the evaluator was configured in a build_executorch_binary POST-hook, but all Observatory.collect() calls happen DURING that function — before the evaluator existed. The fix removes the build_executorch_binary patch entirely and instead configures the evaluator lazily when AccuracyLens.observe() first sees the "Exported Float" record: 1. Extract the float model from the ExportedProgram artifact 2. Use captured dataset (from get_imagenet_dataset patch) as primary source, or fall back to sample inputs captured by PipelineGraphCollectorLens 3. Auto-detect task type, post_process, and metrics 4. Compute golden outputs and build the evaluator PipelineGraphCollectorLens now also captures the sample input tuple from torch.export.export(mod, args, ...) as _last_export_inputs, providing a fallback dataset for AccuracyLens when dataset loader patches don't fire (e.g., custom datasets, non-Qualcomm backends). Changes: - accuracy.py: remove _install_build_binary_patch(), remove _captured_model, add _configure_from_float_model(), update observe() with lazy init - pipeline_graph_collector.py: add _last_export_inputs, capture args[1] in patched_export, clear on uninstall/clear - LENSES.md: new reference doc covering all lenses, observation points, patching strategy, accuracy lens lazy configuration, data source fallback strategy, and custom usage examples Co-Authored-By: Claude Opus 4.6 (1M context) --- .../debugger/observatory/lenses/LENSES.md | 211 ++++++++++++++++++ .../debugger/observatory/lenses/accuracy.py | 124 +++++----- .../lenses/pipeline_graph_collector.py | 6 + 3 files changed, 277 insertions(+), 64 deletions(-) create mode 100644 backends/qualcomm/debugger/observatory/lenses/LENSES.md diff --git a/backends/qualcomm/debugger/observatory/lenses/LENSES.md b/backends/qualcomm/debugger/observatory/lenses/LENSES.md new file mode 100644 index 00000000000..446510dbc23 --- /dev/null +++ b/backends/qualcomm/debugger/observatory/lenses/LENSES.md @@ -0,0 +1,211 @@ +# Observatory Lenses Reference + +## Overview + +Observatory lenses are plugins that observe, analyze, and render model artifacts +at each stage of the ExecuTorch compilation pipeline. Lenses install their own +monkey-patches in `on_session_start()` and remove them in `on_session_end()`. + +## Built-in Lenses + +| Lens | Purpose | Patches? | +|------|---------|----------| +| GraphLens | Renders fx_viewer graph visualization | No | +| MetadataLens | Collects artifact type, node count, environment info | No | +| StackTraceLens | Captures repo-local call stack at collection time | No | +| PipelineGraphCollectorLens | Auto-collects graphs at each pipeline stage | Yes | +| AccuracyLens | Evaluates model accuracy at each stage | Yes | + +--- + +## PipelineGraphCollectorLens + +### Purpose + +Automatically collects graph snapshots at each stage of the export -> quantize -> +lower pipeline by monkey-patching framework-level functions. All patches are +framework-level (torchao, executorch.exir), so they work for ALL backends. + +### Observation Points + +| # | Pipeline Stage | Record Name | Patched Function | Source File | Collected Artifact | +|---|---------------|-------------|-----------------|-------------|-------------------| +| 1 | Export | "Exported Float" | `torch.export.export()` | `torch/export/__init__.py` | Output ExportedProgram | +| 2 | Quantizer Prepare | "Annotated Model" | `prepare_pt2e()` | `torchao/.../quantize_pt2e.py` | Output GraphModule with observers | +| 3 | Quantizer Convert (input) | "Calibrated Model" | `convert_pt2e()` | same | Input GraphModule (post-calibration) | +| 4 | Quantizer Convert (output) | "Quantized Model" | `convert_pt2e()` | same | Output GraphModule with Q/DQ ops | +| 5 | Edge Lowering | "Edge" | `to_edge_transform_and_lower()` | `executorch/exir/program/_program.py` | Output EdgeProgramManager | +| 6 | Edge Transform | "Transformed Edge" | same | same | After transform passes | +| 7 | ETRecord Export | "ETRecord Exported/{method}" | `ETRecord.add_exported_program()` | `executorch/devtools/etrecord/` | Exported program | +| 8 | ETRecord Edge | "ETRecord Edge/{method}" | `ETRecord.add_edge_dialect_program()` | same | Edge dialect program | +| 9 | ETRecord Extra | "ETRecord Extra/{module}" | `ETRecord.add_extra_export_modules()` | same | Extra modules | + +### Patching Strategy + +Each patch follows the same pattern: +1. Save original function in `_originals[key]` +2. Create wrapper that calls `Observatory.collect(name, artifact)` then calls original +3. Replace function in module namespace via `setattr` +4. On session end, restore all originals + +The `to_edge_transform_and_lower` patch also forces `generate_etrecord=True` to +ensure ETRecord collection fires (rows 7-9). + +### Additional Data Captured + +The `torch.export.export` patch also stores the sample input arguments as +`_last_export_inputs` for use by AccuracyLens as a fallback dataset. + +--- + +## AccuracyLens + +### Purpose + +Evaluates model accuracy at each collected pipeline stage by running inference +with a dataset and computing metrics (TopK, PSNR, CosineSimilarity, etc.). + +### How It Works + +AccuracyLens depends on PipelineGraphCollectorLens for graph collection timing. +It configures itself lazily when it first observes the "Exported Float" record: + +``` +Observatory.collect("Exported Float", exported_program) + -> AccuracyLens.observe() triggered + -> Recognizes record name "Exported Float" + -> Extracts float model from ExportedProgram + -> Configures evaluator with captured dataset + auto-detected metrics + -> Runs evaluation on float model -> returns accuracy digest + -> Evaluator is now ready for subsequent records + +Observatory.collect("Quantized Model", quantized_model) + -> AccuracyLens.observe() triggered + -> Evaluator already configured + -> Runs evaluation on quantized model -> returns accuracy digest +``` + +### Data Sources and Fallback Strategy + +| Data | Primary Source | Fallback | When Fallback Triggers | +|------|---------------|----------|----------------------| +| Dataset (inputs) | `get_imagenet_dataset` patch | Sample input from `torch.export.export` args | Custom dataset loader, non-Qualcomm backend | +| Targets (labels) | `get_imagenet_dataset` / `get_masked_language_model_dataset` patch | None (skip target-specific metrics) | Custom dataset, non-classification task | +| Float model | "Exported Float" artifact (ExportedProgram) | -- | Always available | +| Golden outputs | Computed from float model + dataset | -- | Always available if dataset exists | +| Post-process | Auto-detected from model output type | Identity function | Detection failure | + +**Fallback behavior when dataset patches don't fire:** +- AccuracyLens uses the single sample input captured by PipelineGraphCollectorLens +- Only PSNR and CosineSimilarity metrics are available (no TopK/MaskedTokenAccuracy) +- This still provides useful signal-to-noise comparison between float and quantized models + +### Dataset Loader Patches + +| Patched Function | Module | Return Format | What's Captured | +|-----------------|--------|---------------|-----------------| +| `get_imagenet_dataset` | `examples.qualcomm.utils` | `(List[Tuple[Tensor]], List[Tensor])` | inputs + class targets | +| `get_masked_language_model_dataset` | `examples.qualcomm.utils` | `(List[Tuple[Tensor, Tensor]], List[Tensor])` | inputs + masked targets | + +### Auto-Detection + +**Task type** (from target format): +- Targets contain -100 values -> MLM mode -> uses `MaskedTokenAccuracy` + `MLMEvaluator` +- Otherwise -> Classification mode -> uses `TopKAccuracy` + `StandardEvaluator` + +**Post-process** (from model output type): +- `torch.Tensor` -> identity +- Has `.logits` attribute -> `lambda x: x.logits` (HuggingFace models) +- Tuple -> `lambda x: x[0]` + +### Default Metrics + +| Task Type | Metrics | +|-----------|---------| +| Classification (with targets) | TopKAccuracy(k=1), TopKAccuracy(k=5), PSNR, CosineSimilarity | +| MLM (with targets) | MaskedTokenAccuracy, PSNR, CosineSimilarity | +| No targets (fallback) | PSNR, CosineSimilarity | + +--- + +## Custom Usage + +### Providing a Custom Dataset and Evaluator + +For scripts with custom datasets not covered by the auto-patches, users can +provide their own evaluator via the Observatory config: + +```python +from executorch.backends.qualcomm.debugger.observatory import Observatory +from executorch.backends.qualcomm.debugger.observatory.lenses.accuracy import ( + StandardEvaluator, TopKAccuracy, PSNR, CosineSimilarity +) + +# Prepare your dataset and golden outputs +dataset = [...] # List of input tuples +targets = [...] # Ground truth labels +golden = [model(*inp) for inp in dataset] # Reference outputs + +evaluator = StandardEvaluator( + dataset=dataset, + metrics=[ + TopKAccuracy(targets, k=1), + PSNR(golden), + CosineSimilarity(golden), + ], + post_process=lambda x: x.logits, # optional +) + +config = {"accuracy": {"evaluator": evaluator}} + +with Observatory.enable_context(config=config): + build_executorch_binary(model, inputs, ...) +``` + +### Using MLMEvaluator for Language Models + +```python +from executorch.backends.qualcomm.debugger.observatory.lenses.accuracy import ( + MLMEvaluator, MaskedTokenAccuracy, PSNR +) + +evaluator = MLMEvaluator( + dataset=inputs, + metrics=[MaskedTokenAccuracy(targets), PSNR(golden)], +) + +config = {"accuracy": {"evaluator": evaluator}} +with Observatory.enable_context(config=config): + ... +``` + +### Custom Metrics + +Implement the `Metric` protocol: + +```python +class MyMetric: + def name(self) -> str: + return "my_metric" + + def calculate(self, predictions: List[torch.Tensor]) -> float: + # Your metric logic here + return score + +evaluator = StandardEvaluator( + dataset=dataset, + metrics=[MyMetric(), PSNR(golden)], +) +``` + +### Disabling Accuracy Evaluation + +Via CLI: +```bash +python -m executorch.backends.qualcomm.debugger.observatory.cli --no-accuracy script.py ... +``` + +Via config: +```python +config = {"accuracy": {"enabled": False}} +``` diff --git a/backends/qualcomm/debugger/observatory/lenses/accuracy.py b/backends/qualcomm/debugger/observatory/lenses/accuracy.py index be51fd26d9e..93c6692eabb 100644 --- a/backends/qualcomm/debugger/observatory/lenses/accuracy.py +++ b/backends/qualcomm/debugger/observatory/lenses/accuracy.py @@ -6,25 +6,25 @@ """Accuracy Evaluation Lens — auto-captures datasets and evaluates model accuracy. -This lens patches dataset loaders and build functions to transparently capture -evaluation data, then runs accuracy metrics on collected model artifacts. +This lens patches dataset loaders to transparently capture evaluation data, then +lazily configures an evaluator when the first "Exported Float" record is observed. Patches installed on session start: - get_imagenet_dataset: captures (inputs, targets) for ImageNet classification - get_masked_language_model_dataset: captures (inputs, targets) for MLM tasks - - build_executorch_binary: captures float model + dataset references -Auto-configuration: - - Detects task type (classification vs MLM) from target format - - Auto-selects metrics (TopK, PSNR, CosineSimilarity, MaskedTokenAccuracy) - - Auto-detects post_process function from model output type +Lazy configuration (on first "Exported Float" observe): + - Extracts float model from ExportedProgram + - Uses captured dataset (primary) or sample input from PipelineGraphCollectorLens (fallback) + - Auto-detects task type, post_process, and metrics + - Computes golden outputs for PSNR/CosineSimilarity """ from __future__ import annotations import logging from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional, Protocol, Union +from typing import Any, Callable, Dict, List, Optional, Protocol import numpy as np import torch @@ -250,12 +250,16 @@ def run_inference(self, model: Any, dataset: List[Any]) -> List[torch.Tensor]: class AccuracyLens(Lens): - """Evaluates model accuracy at each collected pipeline stage.""" + """Evaluates model accuracy at each collected pipeline stage. + + Configures itself lazily when it first observes the "Exported Float" record, + extracting the float model from the ExportedProgram and building an evaluator + with captured dataset + auto-detected metrics. + """ _installed: bool = False _originals: Dict[str, Any] = {} - _captured_model: Any = None _captured_dataset: Optional[List[Any]] = None _captured_targets: Optional[List[Any]] = None _golden_outputs: Optional[List[torch.Tensor]] = None @@ -272,7 +276,6 @@ def on_session_start(cls, context: ObservationContext) -> None: if cls._installed: return cls._install_dataset_patches() - cls._install_build_binary_patch() cls._installed = True @classmethod @@ -287,7 +290,6 @@ def clear(cls) -> None: @classmethod def _clear_state(cls) -> None: - cls._captured_model = None cls._captured_dataset = None cls._captured_targets = None cls._golden_outputs = None @@ -297,6 +299,12 @@ def _clear_state(cls) -> None: @classmethod def observe(cls, artifact: Any, context: ObservationContext) -> Any: + record_name = context.shared_state.get("record_name", "") + + # Lazily configure evaluator on first "Exported Float" record + if record_name == "Exported Float" and cls._evaluator is None: + cls._configure_from_float_model(artifact) + acc_config = context.config.get("accuracy", {}) evaluator = acc_config.get("evaluator") or cls._evaluator if not evaluator: @@ -395,6 +403,46 @@ def _compute_golden_outputs( golden.append(processed) return golden + @classmethod + def _configure_from_float_model(cls, artifact: Any) -> None: + """Lazily configure evaluator from the "Exported Float" ExportedProgram.""" + try: + is_ep = hasattr(artifact, "module") and callable(artifact.module) + model = artifact.module() if is_ep else artifact + + # Primary: captured dataset from dataset loader patches + # Fallback: sample input from PipelineGraphCollectorLens + dataset = cls._captured_dataset + if dataset is None: + from .pipeline_graph_collector import PipelineGraphCollectorLens + + sample_inputs = PipelineGraphCollectorLens._last_export_inputs + if sample_inputs is not None: + dataset = [sample_inputs] + cls._captured_dataset = dataset + logging.info( + "[AccuracyLens] Using sample input from torch.export.export as fallback dataset" + ) + + if dataset is None: + logging.debug("[AccuracyLens] No dataset available, skipping auto-config") + return + + cls._task_type = cls._detect_task_type(cls._captured_targets or []) + cls._post_process = cls._auto_detect_post_process(model, dataset) + cls._golden_outputs = cls._compute_golden_outputs( + model, dataset, cls._post_process + ) + cls._evaluator = cls._build_default_evaluator() + if cls._evaluator: + logging.info( + "[AccuracyLens] Auto-configured %s evaluator with %d metrics", + cls._task_type, + len(cls._evaluator.metrics), + ) + except Exception as e: + logging.warning("[AccuracyLens] Auto-config from float model failed: %s", e) + @classmethod def _build_default_evaluator(cls) -> Optional[Evaluator]: if cls._captured_dataset is None: @@ -474,58 +522,6 @@ def patched_mlm(*args, **kwargs): "[AccuracyLens] qualcomm utils not available, skipping dataset patches" ) - @classmethod - def _install_build_binary_patch(cls) -> None: - try: - import executorch.examples.qualcomm.utils as utils_module - - if not hasattr(utils_module, "build_executorch_binary"): - return - - original = utils_module.build_executorch_binary - cls._originals["build_executorch_binary"] = original - - def patched_build(model, inputs, soc_model, file_name, dataset, **kwargs): - cls._captured_model = model - if cls._captured_dataset is None: - cls._captured_dataset = ( - dataset if isinstance(dataset, list) else None - ) - - result = original(model, inputs, soc_model, file_name, dataset, **kwargs) - - # Auto-configure evaluator after build completes - if cls._evaluator is None and cls._captured_dataset is not None: - try: - cls._task_type = cls._detect_task_type( - cls._captured_targets or [] - ) - cls._post_process = cls._auto_detect_post_process( - model, cls._captured_dataset - ) - cls._golden_outputs = cls._compute_golden_outputs( - model, cls._captured_dataset, cls._post_process - ) - cls._evaluator = cls._build_default_evaluator() - if cls._evaluator: - logging.info( - "[AccuracyLens] Auto-configured %s evaluator with %d metrics", - cls._task_type, - len(cls._evaluator.metrics), - ) - except Exception as e: - logging.warning( - "[AccuracyLens] Auto-config failed: %s", e - ) - return result - - utils_module.build_executorch_binary = patched_build - logging.info("[AccuracyLens] Installed patch: build_executorch_binary") - except ImportError: - logging.debug( - "[AccuracyLens] qualcomm utils not available, skipping build patch" - ) - @classmethod def _uninstall_all(cls) -> None: if not cls._installed: diff --git a/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py b/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py index 9d7e486b229..c88b43cde58 100644 --- a/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py +++ b/backends/qualcomm/debugger/observatory/lenses/pipeline_graph_collector.py @@ -39,6 +39,7 @@ class PipelineGraphCollectorLens(Lens): _installed: bool = False _originals: Dict[str, Any] = {} _collect_fn: Optional[Callable[[str, Any], None]] = None + _last_export_inputs: Optional[tuple] = None @classmethod def get_name(cls) -> str: @@ -65,6 +66,7 @@ def on_session_end(cls, context: ObservationContext) -> None: @classmethod def clear(cls) -> None: cls._uninstall_all() + cls._last_export_inputs = None @classmethod def observe(cls, artifact: Any, context: ObservationContext) -> Any: @@ -94,6 +96,9 @@ def _install_export_patch(cls) -> None: def patched_export(*args, **kwargs): result = original(*args, **kwargs) try: + # torch.export.export(mod, args, ...) — args[1] is the input tuple + if len(args) >= 2: + cls._last_export_inputs = args[1] cls._collect_fn("Exported Float", result) except Exception as exc: logging.debug( @@ -325,5 +330,6 @@ def _uninstall_all(cls) -> None: cls._originals.clear() cls._collect_fn = None + cls._last_export_inputs = None cls._installed = False logging.info("[PipelineGraphCollector] Uninstalled all patches") From c5070eb8bb605bdc4ee0c88e15d31a67edb9ad2d Mon Sep 17 00:00:00 2001 From: boyuc Date: Tue, 24 Mar 2026 20:05:47 +0800 Subject: [PATCH 22/65] observatory(ui): auto-hide panels, theme sync, compare snap fix, fullscreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five UI improvements to the Observatory HTML report: 1. Auto-hide sidebar (main.css, 02_layout.js) The left index pane is now position:fixed and hidden by default (translateX(-100%)). A 12px invisible trigger strip on the left edge reveals it on hover; the pane itself stays open while hovered. Main content takes the full viewport width — no left margin needed. 2. Auto-hide header (main.css, 02_layout.js) The header is now position:fixed and hidden by default (translateY(-100%)). A 10px invisible trigger strip at the top edge reveals it on hover. Both panels overlay content rather than consuming layout space, maximising the graph viewer area. 3. Theme sync Observatory → fx_viewer (04_actions.js, 03_blocks.js) setTheme() now propagates the theme to all live mountedViewers via viewer.setTheme(theme) and to all mountedCompares by iterating compare.viewers. New viewers receive themeName in their initial state so they open in the current theme without a separate setTheme call. 4. Compare snap merge-on-write (03_blocks.js) Root cause: all viewers in a compare group share one compareStateCache entry. Each viewer registers a statechange listener that overwrites the snap. When viewer B pans after viewer A selects a node, viewer B's statechange fires with selectedNodeId=null and wipes the selection. On the next restore both viewers see null → both zoomToFit(). Fix: the statechange write is now a merge. selectedNodeId is only updated when the incoming value is non-null, preserving any selection set by any viewer until another viewer explicitly selects a different node. camera/activeExtensions/colorBy are always overwritten with the latest value. Restore priority order (unchanged logic, now actually reachable): - node exists in this viewer's graph → selectNode + animate - node not found (different record) or no selection → zoomToFit() - no snapshot at all → init() default positioning 5. Fullscreen button — correct API key (03_blocks.js) Previous implementation passed ui.controls.fullscreenButton which is not a recognised fx_viewer config key. Correct key per RFC_FX_VIEWER_API_INTERFACE.md and fx_graph_viewer.js default (fullscreen: { enabled: true, button: false }) is layout.fullscreen.button. Changed ViewerCtor.create() call to layout: { preset, fullscreen: { button: true } }. README section 12.2 updated to document the merge-on-write behaviour, the corrected restore priority order (no longer uses setState({camera}) as fallback), and the per-viewer node-existence check semantics. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../qualcomm/debugger/observatory/README.md | 18 +++++-- .../observatory/templates/css/main.css | 53 +++++++++++++++++-- .../observatory/templates/js/02_layout.js | 2 + .../observatory/templates/js/03_blocks.js | 23 +++++--- .../observatory/templates/js/04_actions.js | 15 ++++++ 5 files changed, 93 insertions(+), 18 deletions(-) diff --git a/backends/qualcomm/debugger/observatory/README.md b/backends/qualcomm/debugger/observatory/README.md index d5379bdc0b0..a69cade1b1e 100644 --- a/backends/qualcomm/debugger/observatory/README.md +++ b/backends/qualcomm/debugger/observatory/README.md @@ -948,13 +948,21 @@ composition or graphRef. Consequences: - Adding or removing records from the compare pool restores the same camera and layers. - Switching from single-record mode back to compare mode restores the compare state. -- All viewers in the same compare block share one snapshot (they are synced via - `FXGraphCompare` anyway, so their states converge). + +**Merge-on-write for `selectedNodeId`**: all viewers in the same compare block share one +snapshot and each registers a `statechange` listener that writes to it. To prevent a +viewer with no selection (e.g. viewer B panning after viewer A selected a node) from +overwriting the saved selection with `null`, the write is a **merge**: `selectedNodeId` +is only updated when the incoming value is non-null. `camera`, `activeExtensions`, and +`colorBy` are always overwritten with the latest value. The selection is cleared from the +snapshot only when another viewer explicitly selects a different node. On re-entry, each new viewer is seeded from the snapshot in priority order: -1. `selectedNodeId` present and node exists in graph → `selectNode` + animate. -2. `camera` present, no node selection → `setState({ camera })` to restore pan/zoom. -3. No snapshot → `init()` runs normally (zoom-to-fit or first-node centering). +1. `selectedNodeId` present and node exists in **this viewer's** graph → + `selectNode(id, { animate: true })`. +2. Node not found in this viewer's graph (different record) or no selection → + `zoomToFit()`. +3. No snapshot at all → `init()` runs normally (zoom-to-fit or first-node centering). Layers (`activeExtensions`, `colorBy`) are always restored from the snapshot when available, regardless of which priority path is taken for camera/selection. diff --git a/backends/qualcomm/debugger/observatory/templates/css/main.css b/backends/qualcomm/debugger/observatory/templates/css/main.css index f3edd8e9e2e..727ffb1e166 100644 --- a/backends/qualcomm/debugger/observatory/templates/css/main.css +++ b/backends/qualcomm/debugger/observatory/templates/css/main.css @@ -74,6 +74,16 @@ height: 100vh; } + /* Header trigger zone — invisible strip at top */ + .header-trigger { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 10px; + z-index: 201; + } + header { background: var(--bg-secondary); color: var(--text-primary); @@ -82,14 +92,22 @@ justify-content: space-between; align-items: center; flex-shrink: 0; - z-index: 10; - box-shadow: 0 2px 4px var(--shadow); border-bottom: 1px solid var(--border-color); - position: sticky; + position: fixed; top: 0; - transition: transform 0.3s ease; + left: 0; + right: 0; + transform: translateY(-100%); + transition: transform 0.25s ease, box-shadow 0.25s ease; + z-index: 200; } - + + .header-trigger:hover ~ header, + header:hover { + transform: translateY(0); + box-shadow: 0 4px 12px var(--shadow); + } + header.hidden { transform: translateY(-100%); } @@ -136,14 +154,39 @@ overflow: hidden; } + /* Sidebar trigger zone — invisible strip on left edge */ + .index-pane-trigger { + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: 12px; + z-index: 200; + } + /* Index Pane (Sidebar) */ .index-pane { + position: fixed; + left: 0; + top: 0; + bottom: 0; width: 300px; background: var(--bg-secondary); border-right: 1px solid var(--border-color); display: flex; flex-direction: column; flex-shrink: 0; + z-index: 199; + transform: translateX(-100%); + transition: transform 0.25s ease, box-shadow 0.25s ease; + box-shadow: none; + } + + /* Show when trigger zone or pane itself is hovered */ + .index-pane-trigger:hover ~ .index-pane, + .index-pane:hover { + transform: translateX(0); + box-shadow: 4px 0 16px var(--shadow); } .index-header { diff --git a/backends/qualcomm/debugger/observatory/templates/js/02_layout.js b/backends/qualcomm/debugger/observatory/templates/js/02_layout.js index 5e36e03b095..bb674c048da 100644 --- a/backends/qualcomm/debugger/observatory/templates/js/02_layout.js +++ b/backends/qualcomm/debugger/observatory/templates/js/02_layout.js @@ -6,6 +6,7 @@ function renderLayout() { const icon = state.theme === 'dark' ? '☀️' : '🌙'; OBS.app.innerHTML = ` +

    ${escapeHtml(state.data.title || 'Observatory Report')}

    @@ -20,6 +21,7 @@
    +