Skip to content

Commit 3521c1a

Browse files
quic-boyucclaude
andcommitted
observatory: add observe_pass decorator and collect() name deduplication
Add observe_pass, a decorator that wraps any PassBase subclass or callable pass to automatically collect input/output graphs via Observatory. Names are derived from the class/function name and deduplicated by collect() itself (#2, pytorch#3, ...) so repeated calls never silently overwrite records. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 18c4bab commit 3521c1a

6 files changed

Lines changed: 409 additions & 9 deletions

File tree

devtools/observatory/README.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,38 @@ This produces:
7171
### Python API
7272

7373
```python
74-
from executorch.devtools.observatory import Observatory
74+
from executorch.devtools.observatory import Observatory, observe_pass
75+
76+
# Wrap passes for automatic graph collection
77+
pass_a = observe_pass(SomePass())
7578

7679
Observatory.clear()
7780
with Observatory.enable_context():
7881
# Auto: Lenses can auto-insert collection points by monkey patching when entering context
7982
# Manual: Insert the collection point anywhere
8083
Observatory.collect("step_0", graph_module)
8184

85+
# observe_pass: auto-collects input and output graphs
86+
result = pass_a(graph_module)
87+
# collects "SomePass/input" and "SomePass/output"
88+
8289
Observatory.export_html_report("/tmp/report.html")
8390
```
8491

8592
## Core concepts
8693

94+
### `observe_pass` decorator
95+
96+
The `observe_pass` decorator wraps any pass (PassBase subclass or callable) to automatically collect graphs via Observatory. By default it captures both input and output graphs, making pass debugging a one-line change:
97+
98+
```python
99+
observed = observe_pass(SomePass()) # wrap once
100+
result = observed(graph_module) # auto-collects input + output
101+
```
102+
103+
Record names are derived from the class name and auto-deduplicated on repeat calls.
104+
See [USAGE.md](USAGE.md) for the full decorator reference.
105+
87106
### Lenses
88107

89108
A **Lens** is a modular extension that adds domain-specific debugging logic. Each lens can participate in capture, analysis, and visualization. This is what makes Observatory a framework rather than a fixed tool.

devtools/observatory/USAGE.md

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,20 +159,63 @@ Observatory.export_json("pass_debug.json")
159159

160160
### Pass transform debugging
161161

162-
To compare graphs before and after a specific pass:
162+
Use `observe_pass` to automatically collect graphs before and after a pass.
163+
Wrap any `PassBase` subclass instance, callable, or use it as a class decorator:
163164

164165
```python
166+
from executorch.devtools.observatory import Observatory, observe_pass
167+
from executorch.exir.passes.remove_graph_asserts_pass import RemoveGraphAssertsPass
165168

166-
from executorch.devtools.observatory import Observatory
169+
# Wrap pass instances — default collects both input and output graphs
170+
pass_a = observe_pass(RemoveGraphAssertsPass())
171+
pass_b = observe_pass(MyCustomPass())
172+
173+
Observatory.clear()
167174
with Observatory.enable_context():
168-
for name, module in model.named_modules():
169-
before = torch.fx.symbolic_trace(module)
170-
Observatory.collect(f"{name}/before", before)
175+
result_a = pass_a(graph_module)
176+
# collects "RemoveGraphAssertsPass/input" and "RemoveGraphAssertsPass/output"
177+
178+
result_b = pass_b(result_a.graph_module)
179+
# collects "MyCustomPass/input" and "MyCustomPass/output"
180+
181+
# Call again — names auto-deduplicate
182+
result_c = pass_a(result_b.graph_module)
183+
# collects "RemoveGraphAssertsPass/input #2" and "RemoveGraphAssertsPass/output #2"
171184

172-
after = my_transform(before)
173-
Observatory.collect(f"{name}/after", after)
185+
Observatory.export_html_report("pass_debug.html")
174186
```
175187

188+
Control what is collected with boolean flags:
189+
190+
```python
191+
# Collect only the output graph
192+
observed = observe_pass(SomePass(), collect_input=False)
193+
194+
# Collect only the input graph
195+
observed = observe_pass(SomePass(), collect_output=False)
196+
197+
# Override the record name
198+
observed = observe_pass(SomePass(), name="step_1")
199+
```
200+
201+
Use as a class decorator to make all instances observable:
202+
203+
```python
204+
@observe_pass
205+
class MyPass(PassBase):
206+
def call(self, gm):
207+
# ... transform logic ...
208+
return PassResult(gm, True)
209+
210+
# Or with parameters:
211+
@observe_pass(name="Quantize", collect_input=False)
212+
class QuantizePass(PassBase):
213+
def call(self, gm):
214+
...
215+
```
216+
217+
`observe_pass` is a no-op when no Observatory context is active.
218+
176219
### Inside the CLI-wrapped script (zero-code-change)
177220

178221
When running via the CLI, `Observatory.enable_context()` is already active.

devtools/observatory/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
from .observe_pass import observe_pass
78
from .observatory import Observatory
89

9-
__all__ = ["Observatory"]
10+
__all__ = ["Observatory", "observe_pass"]

devtools/observatory/observatory.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ def collect(cls, name: str, artifact: Any) -> None:
165165
Notes:
166166
1. No-op when context is disabled.
167167
2. Record name is exposed via `context.shared_state['record_name']`.
168+
3. Duplicate names are auto-suffixed with #2, #3, … to prevent overwrites.
168169
"""
169170

170171
if any(ignored in name for ignored in cls._ignored_graphs):
@@ -173,6 +174,13 @@ def collect(cls, name: str, artifact: Any) -> None:
173174
if not cls._config_stack:
174175
return
175176

177+
# Deduplicate: if name exists, suffix with #2, #3, ...
178+
if name in cls._records:
179+
n = 2
180+
while f"{name} #{n}" in cls._records:
181+
n += 1
182+
name = f"{name} #{n}"
183+
176184
active_config = cls._config_stack[-1]
177185
ctx = ObservationContext(config=active_config)
178186
ctx.shared_state["record_name"] = name
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Copyright (c) Qualcomm Innovation Center, Inc.
2+
# All rights reserved
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
"""observe_pass — decorator to auto-collect pass input/output via Observatory."""
8+
9+
from __future__ import annotations
10+
11+
import functools
12+
import logging
13+
from typing import Any, Callable, Optional, Type, Union
14+
15+
from torch.fx.passes.infra.pass_base import PassResult
16+
17+
18+
def observe_pass(
19+
target: Union[Callable, Type, None] = None,
20+
*,
21+
name: Optional[str] = None,
22+
collect_input: bool = True,
23+
collect_output: bool = True,
24+
) -> Any:
25+
"""Wrap a pass to auto-collect its graph via Observatory.
26+
27+
Works as a class decorator, instance wrapper, or function wrapper.
28+
29+
Args:
30+
target: A PassBase subclass (class decorator), a pass instance, or a
31+
callable pass. When ``None``, returns a parameterized decorator.
32+
name: Override the record name (default: derived from class/function name).
33+
collect_input: Collect the input graph before the pass runs (default True).
34+
collect_output: Collect the output graph after the pass runs (default True).
35+
"""
36+
collect_both = collect_input and collect_output
37+
38+
def _base_name(obj: Any) -> str:
39+
if name:
40+
return name
41+
if isinstance(obj, type):
42+
return obj.__name__
43+
cls_name = type(obj).__name__
44+
if cls_name == "function":
45+
return getattr(obj, "__name__", "pass")
46+
return cls_name
47+
48+
def _collect_artifact(record_name: str, artifact: Any) -> None:
49+
from .observatory import Observatory
50+
51+
try:
52+
Observatory.collect(record_name, artifact)
53+
except Exception as exc:
54+
logging.debug("[observe_pass] collection failed for %s: %s", record_name, exc)
55+
56+
def _output_graph(gm: Any, result: Any) -> Any:
57+
if isinstance(result, tuple) and hasattr(result, "graph_module"):
58+
return result.graph_module
59+
if result is None:
60+
return gm
61+
return result
62+
63+
def _wrap_callable(fn: Callable) -> Callable:
64+
@functools.wraps(fn)
65+
def wrapper(gm: Any, *args: Any, **kwargs: Any) -> Any:
66+
base = _base_name(fn)
67+
if collect_input:
68+
_collect_artifact(f"{base}/input" if collect_both else base, gm)
69+
result = fn(gm, *args, **kwargs)
70+
if collect_output:
71+
_collect_artifact(
72+
f"{base}/output" if collect_both else base,
73+
_output_graph(gm, result),
74+
)
75+
return result
76+
77+
return wrapper
78+
79+
def _wrap_class(cls: Type) -> Type:
80+
original_call = cls.__call__
81+
82+
@functools.wraps(original_call)
83+
def patched_call(self: Any, gm: Any, *args: Any, **kwargs: Any) -> Any:
84+
base = _base_name(self)
85+
if collect_input:
86+
_collect_artifact(f"{base}/input" if collect_both else base, gm)
87+
result = original_call(self, gm, *args, **kwargs)
88+
if collect_output:
89+
_collect_artifact(
90+
f"{base}/output" if collect_both else base,
91+
_output_graph(gm, result),
92+
)
93+
return result
94+
95+
cls.__call__ = patched_call
96+
return cls
97+
98+
# Dispatch based on how observe_pass was called.
99+
if target is None:
100+
# Parameterized: @observe_pass(name="X", collect_output=False)
101+
def decorator(t: Any) -> Any:
102+
if isinstance(t, type):
103+
return _wrap_class(t)
104+
return _wrap_callable(t)
105+
106+
return decorator
107+
108+
if isinstance(target, type):
109+
return _wrap_class(target)
110+
111+
return _wrap_callable(target)

0 commit comments

Comments
 (0)