已合并
feat:支持融合结果上报的 Python 化 #4070
lfz2812创建于 7月20日
feat:支持融合结果上报的 Python 化 #4070
已合并
lfz2812创建于 7月20日
21 个文件变更+305-56
@@ -38,6 +38,7 @@ __all__ = [
38 "pattern",38 "pattern",
39 "register_decompose_pass",39 "register_decompose_pass",
40 "register_fusion_pass",40 "register_fusion_pass",
41+ "report_fuse",
41]42]
42 43 
43from .pattern import pattern44from .pattern import pattern
@@ -67,6 +68,7 @@ _LAZY_EXPORTS = {
67 "get_registered_passes": ".registry",68 "get_registered_passes": ".registry",
68 "register_decompose_pass": ".registry",69 "register_decompose_pass": ".registry",
69 "register_fusion_pass": ".registry",70 "register_fusion_pass": ".registry",
71+ "report_fuse": ".fuse_inspector",
70}72}
71 73 
72 74 
@@ -29,6 +29,7 @@ __all__: list[str] = [
29 "SubgraphOutput",29 "SubgraphOutput",
30 "SubgraphRewriter",30 "SubgraphRewriter",
31 "can_fuse",31 "can_fuse",
32+ "report_fuse",
32]33]
33 34 
34 35 
@@ -37,6 +38,15 @@ def can_fuse(nodes: typing.Iterable[Node]) -> tuple[bool, str]:
37 ...38 ...
38 39 
39 40 
41+def report_fuse(
42+ nodes_before: typing.Iterable[Node],
43+ nodes_after: typing.Iterable[Node],
44+ context: PassContext,
45+) -> None:
46+ """Report a completed graph fusion rewrite."""
47+ ...
48+ 
49+ 
40class PassContext:50class PassContext:
41 """Compile-time pass context (Python view of C++ ``CustomPassContext``).51 """Compile-time pass context (Python view of C++ ``CustomPassContext``).
42 52 
@@ -295,6 +305,7 @@ class SubgraphRewriter:
295 """Apply whole-subgraph replacement on the main graph."""305 """Apply whole-subgraph replacement on the main graph."""
296 306 
297 @staticmethod307 @staticmethod
308+ @typing.overload
298 def replace(boundary: SubgraphBoundary, replacement: Graph) -> int:309 def replace(boundary: SubgraphBoundary, replacement: Graph) -> int:
299 """Replace the subgraph described by ``boundary`` with ``replacement``.310 """Replace the subgraph described by ``boundary`` with ``replacement``.
300 311 
@@ -316,3 +327,12 @@ class SubgraphRewriter:
316 327 
317 """328 """
318 ...329 ...
330+ 
331+ @staticmethod
332+ @typing.overload
333+ def replace(boundary: SubgraphBoundary, replacement: Graph, *, context: PassContext) -> None:
334+ """Replace a subgraph with automatic fusion inspection and reporting.
335+ 
336+ Raises ``RuntimeError`` when fusion inspection, replacement, or reporting fails.
337+ """
338+ ...
@@ -28,6 +28,7 @@ __all__ = [
28 "borrow_node",28 "borrow_node",
29 "can_fuse",29 "can_fuse",
30 "clone_pattern_matcher_config",30 "clone_pattern_matcher_config",
31+ "report_fuse",
31 "release_graph",32 "release_graph",
32]33]
33 34 
@@ -48,4 +49,5 @@ borrow_match_result = _native.borrow_match_result
48borrow_node = _native.borrow_node49borrow_node = _native.borrow_node
49can_fuse = _native.can_fuse50can_fuse = _native.can_fuse
50clone_pattern_matcher_config = _native.clone_pattern_matcher_config51clone_pattern_matcher_config = _native.clone_pattern_matcher_config
52+report_fuse = _native.report_fuse
51release_graph = _native.release_graph53release_graph = _native.release_graph
@@ -17,7 +17,7 @@ from __future__ import annotations
17from dataclasses import dataclass17from dataclasses import dataclass
18from typing import TYPE_CHECKING, Iterable18from typing import TYPE_CHECKING, Iterable
19 19 
20-from ._native import can_fuse as _native_can_fuse20+from . import _native
21 21 
22if TYPE_CHECKING:22if TYPE_CHECKING:
23 from ge.graph.node import Node23 from ge.graph.node import Node
@@ -31,12 +31,10 @@ class FuseCheckResult:
31 reason: str = ""31 reason: str = ""
32 32 
33 33 
34+report_fuse = _native.report_fuse
35+ 
36+ 
34def can_fuse(nodes: Iterable["Node"]) -> FuseCheckResult:37def can_fuse(nodes: Iterable["Node"]) -> FuseCheckResult:
35 """Check whether ``nodes`` can be safely fused into one node."""38 """Check whether ``nodes`` can be safely fused into one node."""
36- from ge.graph.node import Node39+ ok, reason = _native.can_fuse(nodes)
37- 
38- node_list = list(nodes)
39- if not all(isinstance(node, Node) for node in node_list):
40- raise TypeError("nodes must contain only ge.graph.Node objects")
41- ok, reason = _native_can_fuse(node_list)
42 return FuseCheckResult(ok=ok, reason=reason)40 return FuseCheckResult(ok=ok, reason=reason)
@@ -11,6 +11,7 @@
11#include "binding_utils.h"11#include "binding_utils.h"
12#include "bindings.h"12#include "bindings.h"
13 13 
14+#include <string>
14#include <vector>15#include <vector>
15 16 
16#include "ge/fusion/graph_fuse_inspector_utils.h"17#include "ge/fusion/graph_fuse_inspector_utils.h"
@@ -18,12 +19,12 @@
18namespace ge {19namespace ge {
19namespace python_pass_native {20namespace python_pass_native {
20namespace {21namespace {
21-py::tuple CanFuse(const py::iterable &node_objects) {22+std::vector<GNode> ParseNodes(const py::iterable &node_objects, const char *const argument_name) {
22 const py::object node_type = py::module_::import("ge.graph").attr("Node");23 const py::object node_type = py::module_::import("ge.graph").attr("Node");
23 std::vector<GNode> nodes;24 std::vector<GNode> nodes;
24 for (const py::handle node_object : node_objects) {25 for (const py::handle node_object : node_objects) {
25 if (!py::isinstance(node_object, node_type)) {26 if (!py::isinstance(node_object, node_type)) {
26- throw py::type_error("nodes must contain only ge.graph.Node objects");27+ throw py::type_error(std::string(argument_name) + " must contain only ge.graph.Node objects");
27 }28 }
28 const auto *node = BorrowNodeFromPython(node_object);29 const auto *node = BorrowNodeFromPython(node_object);
29 if (node == nullptr) {30 if (node == nullptr) {
@@ -31,16 +32,38 @@ py::tuple CanFuse(const py::iterable &node_objects) {
31 }32 }
32 nodes.emplace_back(*node);33 nodes.emplace_back(*node);
33 }34 }
35+ return nodes;
36+}
34 37 
38+py::tuple CanFuse(const py::iterable &node_objects) {
39+ const auto nodes = ParseNodes(node_objects, "nodes");
35 AscendString failed_reason;40 AscendString failed_reason;
36 const bool ok = fusion::GraphFuseInspectorUtils::CanFuse(nodes, failed_reason);41 const bool ok = fusion::GraphFuseInspectorUtils::CanFuse(nodes, failed_reason);
37 const char *const reason = failed_reason.GetString();42 const char *const reason = failed_reason.GetString();
38 return py::make_tuple(ok, reason == nullptr ? "" : reason);43 return py::make_tuple(ok, reason == nullptr ? "" : reason);
39}44}
45+ 
46+void ReportFuse(const py::iterable &nodes_before_objects, const py::iterable &nodes_after_objects,
47+ CustomPassContext &context) {
48+ const auto nodes_before = ParseNodes(nodes_before_objects, "nodes_before");
49+ const auto nodes_after = ParseNodes(nodes_after_objects, "nodes_after");
50+ const auto status = fusion::GraphFuseInspectorUtils::ReportFuse(nodes_before, nodes_after, context);
51+ if (status != SUCCESS) {
52+ const auto pass_name = context.GetPassName();
53+ const char *const pass_name_str = pass_name.GetString();
54+ const std::string message =
55+ "Failed to report fusion result, pass_name=" + std::string(pass_name_str == nullptr ? "" : pass_name_str) +
56+ ", status=" + std::to_string(static_cast<uint32_t>(status));
57+ context.SetErrorMessage(AscendString(message.c_str()));
58+ throw std::runtime_error(message);
59+ }
60+}
40} // namespace61} // namespace
41 62 
42void BindGraphFuseInspector(py::module_ &m) {63void BindGraphFuseInspector(py::module_ &m) {
43 m.def("can_fuse", &CanFuse, py::arg("nodes"), "Check whether nodes can be safely fused into one node");64 m.def("can_fuse", &CanFuse, py::arg("nodes"), "Check whether nodes can be safely fused into one node");
65+ m.def("report_fuse", &ReportFuse, py::arg("nodes_before"), py::arg("nodes_after"), py::arg("context"),
66+ "Report the result of a graph fusion rewrite");
44}67}
45 68 
46} // namespace python_pass_native69} // namespace python_pass_native
@@ -11,6 +11,8 @@
11#include "binding_utils.h"11#include "binding_utils.h"
12#include "bindings.h"12#include "bindings.h"
13 13 
14+#include <string>
15+ 
14#include "ge/fusion/graph_rewriter.h"16#include "ge/fusion/graph_rewriter.h"
15#include "ge/fusion/subgraph_boundary.h"17#include "ge/fusion/subgraph_boundary.h"
16 18 
@@ -30,6 +32,41 @@ NodeIo BuildNodeIo(const py::handle &node_obj, int64_t index) {
30 }32 }
31 return NodeIo{*node_ptr, index};33 return NodeIo{*node_ptr, index};
32}34}
35+ 
36+void ReplaceWithContext(const SubgraphBoundary &boundary, const py::handle &replacement_graph,
37+ CustomPassContext &context) {
38+ auto *graph_ptr = BorrowGraphFromPython(replacement_graph);
39+ if (graph_ptr == nullptr) {
40+ throw std::runtime_error("Graph handle is empty");
41+ }
42+ const auto status = SubgraphRewriter::Replace(boundary, *graph_ptr, context);
43+ if (status != SUCCESS) {
44+ const auto pass_name = context.GetPassName();
45+ const char *const pass_name_str = pass_name.GetString();
46+ const std::string message = "SubgraphRewriter::Replace with context failed, pass_name=" +
47+ std::string(pass_name_str == nullptr ? "" : pass_name_str) +
48+ ", status=" + std::to_string(static_cast<uint32_t>(status));
49+ context.SetErrorMessage(AscendString(message.c_str()));
50+ throw std::runtime_error(message);
51+ }
52+}
53+ 
54+void BindSubgraphRewriter(py::module_ &m) {
55+ py::class_<SubgraphRewriter>(m, "SubgraphRewriter", "Subgraph rewriter")
56+ .def_static(
57+ "replace",
58+ [](const SubgraphBoundary &boundary, const py::handle &replacement_graph) -> uint32_t {
59+ auto *graph_ptr = BorrowGraphFromPython(replacement_graph);
60+ if (graph_ptr == nullptr) {
61+ throw std::runtime_error("Graph handle is empty");
62+ }
63+ // SubgraphRewriter::Replace(const Graph&) will copy the replacement graph internally.
64+ return static_cast<uint32_t>(SubgraphRewriter::Replace(boundary, *graph_ptr));
65+ },
66+ py::arg("boundary"), py::arg("replacement"), "Execute subgraph replacement")
67+ .def_static("replace", &ReplaceWithContext, py::arg("boundary"), py::arg("replacement"), py::kw_only(),
68+ py::arg("context"), "Execute subgraph replacement with fusion inspection and reporting");
69+}
33} // namespace70} // namespace
34 71 
35constexpr size_t kNodeInputTupleSize = 2UL;72constexpr size_t kNodeInputTupleSize = 2UL;
@@ -83,18 +120,7 @@ void BindGraphRewriter(py::module_ &m) {
83 },120 },
84 py::arg("index"), py::arg("output"), "Bind the index-th boundary output to SubgraphOutput");121 py::arg("index"), py::arg("output"), "Bind the index-th boundary output to SubgraphOutput");
85 122 
86- py::class_<SubgraphRewriter>(m, "SubgraphRewriter", "Subgraph rewriter")123+ BindSubgraphRewriter(m);
87- .def_static(
88- "replace",
89- [](const SubgraphBoundary &boundary, const py::handle &replacement_graph) -> uint32_t {
90- auto *graph_ptr = BorrowGraphFromPython(replacement_graph);
91- if (graph_ptr == nullptr) {
92- throw std::runtime_error("Graph handle is empty");
93- }
94- // SubgraphRewriter::Replace(const Graph&) will copy the replacement graph internally.
95- return static_cast<uint32_t>(SubgraphRewriter::Replace(boundary, *graph_ptr));
96- },
97- py::arg("boundary"), py::arg("replacement"), "Execute subgraph replacement");
98}124}
99 125 
100} // namespace python_pass_native126} // namespace python_pass_native
@@ -542,6 +542,7 @@ The run package can carry multiple `ge_py_pass_bridge` native sub-wheels, but th
542- `SubgraphRewriter.replace(boundary, replacement)` - Execute subgraph replacement542- `SubgraphRewriter.replace(boundary, replacement)` - Execute subgraph replacement
543 - `boundary`: `SubgraphBoundary`543 - `boundary`: `SubgraphBoundary`
544 - `replacement`: `ge.graph.Graph` (the replacement graph is copied and reconnected on the C++ side)544 - `replacement`: `ge.graph.Graph` (the replacement graph is copied and reconnected on the C++ side)
545+- `SubgraphRewriter.replace(boundary, replacement, context=context)` - Automatically checks fusion feasibility, replaces the subgraph, and reports the result; returns `None` on success and raises `RuntimeError` on failure
545 546 
546##### 5. Pattern / NodeIo / PatternMatcherConfig547##### 5. Pattern / NodeIo / PatternMatcherConfig
547 548 
@@ -569,6 +570,7 @@ The run package can carry multiple `ge_py_pass_bridge` native sub-wheels, but th
569 570 
570**Main Interfaces**:571**Main Interfaces**:
571- `can_fuse(nodes: Iterable[Node]) -> FuseCheckResult` - Checks stream-label and cycle constraints for fusing a node set into one node572- `can_fuse(nodes: Iterable[Node]) -> FuseCheckResult` - Checks stream-label and cycle constraints for fusing a node set into one node
573+- `report_fuse(nodes_before, nodes_after, context) -> None` - Reports a custom rewrite after graph modification and before old nodes are deleted
572- `FuseCheckResult.ok` - Whether fusion is supported574- `FuseCheckResult.ok` - Whether fusion is supported
573- `FuseCheckResult.reason` - Why fusion is not supported; empty on success575- `FuseCheckResult.reason` - Why fusion is not supported; empty on success
574 576 
@@ -577,6 +579,8 @@ The native binding converts the Python `Node` iterable to `std::vector<GNode>`,
577immutable dataclass.579immutable dataclass.
578Business-level rejection returns `FuseCheckResult(False, reason)`; invalid input types and stale Node handles raise580Business-level rejection returns `FuseCheckResult(False, reason)`; invalid input types and stale Node handles raise
579Python exceptions.581Python exceptions.
582+`report_fuse` sets the context error message and raises `RuntimeError` on failure. An empty `nodes_after` represents
583+a deletion-only rewrite.
580 584 
581##### 6. FusionBasePass Class585##### 6. FusionBasePass Class
582 586 
@@ -254,6 +254,9 @@ Add new `ge.passes` package, providing following public interfaces:
254- `capture_tensor`254- `capture_tensor`
255- `create_pattern`255- `create_pattern`
256- `create_replacement`256- `create_replacement`
257+- `FuseCheckResult`
258+- `can_fuse`
259+- `report_fuse`
257- `load_pass_plugins`260- `load_pass_plugins`
258- `get_registered_passes`261- `get_registered_passes`
259 262 
@@ -1088,6 +1088,7 @@
1088 - [pattern](python/ge/passes/pattern.md)1088 - [pattern](python/ge/passes/pattern.md)
1089 - [register\_decompose\_pass](python/ge/passes/register_decompose_pass.md)1089 - [register\_decompose\_pass](python/ge/passes/register_decompose_pass.md)
1090 - [register\_fusion\_pass](python/ge/passes/register_fusion_pass.md)1090 - [register\_fusion\_pass](python/ge/passes/register_fusion_pass.md)
1091+ - [report\_fuse](python/ge/passes/report_fuse.md)
1091 1092 
1092 - [AttrValueType](python/ge/AttrValueType.md)1093 - [AttrValueType](python/ge/AttrValueType.md)
1093 - [DataType](python/ge/DataType.md)1094 - [DataType](python/ge/DataType.md)
@@ -13,6 +13,9 @@
13```python13```python
14@staticmethod14@staticmethod
15replace(boundary: SubgraphBoundary, replacement: Graph) -> int15replace(boundary: SubgraphBoundary, replacement: Graph) -> int
16+ 
17+@staticmethod
18+replace(boundary: SubgraphBoundary, replacement: Graph, *, context: PassContext) -> None
16```19```
17 20 
18## 参数说明21## 参数说明
@@ -21,25 +24,56 @@ replace(boundary: SubgraphBoundary, replacement: Graph) -> int
21| --- | --- | --- |24| --- | --- | --- |
22| boundary | 输入 | 子图边界,类型为SubgraphBoundary,描述待替换子图的输入/输出。 |25| boundary | 输入 | 子图边界,类型为SubgraphBoundary,描述待替换子图的输入/输出。 |
23| replacement | 输入 | 替换图,类型为ge.graph.Graph。 |26| replacement | 输入 | 替换图,类型为ge.graph.Graph。 |
27+| context | 输入 | 可选。当前Pass的PassContext,由GE注入到`FusionBasePass.run(graph, context)`中。传入后,底层自动执行融合可行性检查、子图替换和融合结果上报。 |
24 28 
25## 返回值说明29## 返回值说明
26 30 
27| 类型 | 说明 |31| 类型 | 说明 |
28| --- | --- |32| --- | --- |
29-| int | 返回C++ Status的整数形式。失败时可查看日志中关于边界完整性与索引对齐的信息。 |33+| int | 未传入`context`时,返回C++ Status的整数形式。失败时可查看日志中关于边界完整性与索引对齐的信息。 |
34+| None | 传入`context`时,成功返回`None`。replacement graph handle失效,或融合检查、子图替换、融合结果上报失败时抛出`RuntimeError`,失败信息会同步到`context`。 |
30 35 
31## 约束说明36## 约束说明
32 37 
33-replacement必须是非空的图包装器;该图会在C++侧被**拷贝**,但仍需遵循GE对Python Graph所有权的规则。38+- replacement必须是非空的图包装器;该图会在C++侧被**拷贝**,但仍需遵循GE对Python Graph所有权的规则。
39+- 推荐在`FusionBasePass.run(graph, context)`中传入`context`,由底层完成融合检查和结果上报。
40+- 不传入`context`时保持原有行为,不自动执行融合结果上报。
34 41 
35## 调用示例42## 调用示例
36 43 
44+传入`context`和不传入`context`时的逻辑差异如下:
45+ 
46+| 调用方式 | 融合可行性检查和结果上报 | 成功返回值 | 失败处理 |
47+| --- | --- | --- | --- |
48+| 不传入`context` | 保持原有行为,不自动执行本接口新增的融合可行性检查和融合结果上报 | C++的`Status`整数形式 | 用户检查返回值 |
49+| 传入`context` | 自动执行融合可行性检查和融合结果上报 | `None` | 抛出`RuntimeError`,并尽量将失败信息同步到`context` |
50+ 
37```python51```python
38from ge.passes import SubgraphBoundary, SubgraphInput, SubgraphOutput, SubgraphRewriter52from ge.passes import SubgraphBoundary, SubgraphInput, SubgraphOutput, SubgraphRewriter
39 53 
40-# n0、out_node为主图已存在的节点,replacement_graph为已构建好的替换图54+# 以下代码位于FusionBasePass.run(graph, context)
55+# n0、out_node为主图中已存在的节点,replacement_graph为已构建好的替换图。
41b = SubgraphBoundary()56b = SubgraphBoundary()
42b.add_input(0, SubgraphInput([(n0, 0), (n0, 1)]))57b.add_input(0, SubgraphInput([(n0, 0), (n0, 1)]))
43b.add_output(0, SubgraphOutput(out_node, 0))58b.add_output(0, SubgraphOutput(out_node, 0))
44-ret = SubgraphRewriter.replace(b, replacement_graph)59+```
60+ 
61+完成`boundary`构造后,从以下两种调用方式中选择一种:
62+ 
63+### 不传入context
64+ 
65+保持原有行为,由用户检查返回值。
66+ 
67+```python
68+ret = SubgraphRewriter.replace(b, replacement_graph)
69+if ret != 0:
70+ raise RuntimeError(f"SubgraphRewriter.replace failed, ret={ret}")
71+```
72+ 
73+### 传入context
74+ 
75+自动执行融合可行性检查和融合结果上报。成功时返回`None`,失败时抛出`RuntimeError`
76+ 
77+```python
78+SubgraphRewriter.replace(b, replacement_graph, context=context)
45```79```
@@ -0,0 +1,77 @@
1+# report\_fuse
2+ 
3+## 产品支持情况
4+ 
5+全量芯片支持。
6+ 
7+## 功能说明
8+ 
9+GE融合Pass支持以下两种改图方式:
10+ 
11+- 传统直接改图方式:用户在[FusionBasePass.run(graph, context)](FusionBasePass/run.md)中直接调用Graph接口修改原图,例如增删边、删除节点或重连节点。由于改图过程由用户控制,框架无法自动确定融合前后的节点集合。用户需要在改图前调用`can_fuse`执行融合可行性检查,完成自定义改图后、删除旧节点前调用本函数上报融合结果。
12+- 基于子图替换的方式:这是后续提供的高效改图方式。用户通过boundary描述待替换子图的边界,通过replacement描述替换图,再调用[SubgraphRewriter.replace](SubgraphRewriter/replace.md)完成改图。传入`context`时,该接口会自动完成融合可行性检查、子图替换和融合结果上报,无需单独调用`can_fuse`和本函数。
13+ 
14+本函数用于传统直接改图方式。函数根据用户传入的融合前、融合后节点集合更新融合维测信息,并记录当前Pass名称。
15+ 
16+## 函数原型
17+ 
18+```python
19+report_fuse(
20+ nodes_before: Iterable[Node],
21+ nodes_after: Iterable[Node],
22+ context: PassContext,
23+) -> None
24+```
25+ 
26+## 参数说明
27+ 
28+| 参数名 | 输入/输出 | 说明 |
29+| --- | --- | --- |
30+| nodes_before | 输入 | 融合前的连通节点集合,元素必须为ge.graph.Node对象。 |
31+| nodes_after | 输入 | 融合后的连通节点集合,元素必须为ge.graph.Node对象;空集合表示只删除旧节点、不新增节点。 |
32+| context | 输入 | 当前Pass的PassContext,由GE注入到`FusionBasePass.run(graph, context)`中。 |
33+ 
34+## 返回值说明
35+ 
36+成功时返回`None`。节点集合中包含非ge.graph.Node对象,或`context`不是PassContext时抛出`TypeError`;Node handle失效或底层融合结果上报失败时抛出`RuntimeError`,失败信息会同步到`context`
37+ 
38+## 约束说明
39+ 
40+- 必须先完成自定义改图,再调用本函数。
41+- 必须在旧节点被删除或失效前调用本函数,确保`nodes_before`仍然有效。
42+- `nodes_before`中的节点必须属于同一张图;非空的`nodes_after`也必须属于该图。
43+- `PassContext`仅在当前Pass同步调用期间有效,不应保存到实例属性或其他线程中使用。
44+ 
45+## 调用示例
46+ 
47+以下示例采用传统直接改图方式,删除一个仅有一个数据输入和一个数据输出的中间节点,并将其上游节点直接连接到所有下游节点。
48+ 
49+```python
50+from ge.passes import can_fuse, report_fuse
51+ 
52+def remove_intermediate_node(graph, node, context):
53+ nodes_before = [node]
54+ result = can_fuse(nodes_before)
55+ if not result.ok:
56+ context.set_error_message(result.reason)
57+ return False
58+ 
59+ input_node, input_output_index = node.get_in_data_nodes_and_port_indexes(0)
60+ consumers = list(node.get_out_data_nodes_and_port_indexes(0))
61+ 
62+ graph.remove_edge(input_node, input_output_index, node, 0)
63+ for consumer, consumer_input_index in consumers:
64+ graph.remove_edge(node, 0, consumer, consumer_input_index)
65+ graph.add_data_edge(
66+ input_node,
67+ input_output_index,
68+ consumer,
69+ consumer_input_index,
70+ )
71+ 
72+ # 本次改图未新增节点,因此nodes_after传入空列表。
73+ # 上报完成前,nodes_before中的节点必须保持有效。
74+ report_fuse(nodes_before, [], context)
75+ graph.remove_node(node)
76+ return True
77+```
@@ -541,6 +541,7 @@ run 包可携带多个 `ge_py_pass_bridge` native 子 wheel,但安装脚本只
541- `SubgraphRewriter.replace(boundary, replacement)` - 执行子图替换541- `SubgraphRewriter.replace(boundary, replacement)` - 执行子图替换
542 - `boundary``SubgraphBoundary`542 - `boundary``SubgraphBoundary`
543 - `replacement``ge.graph.Graph`(replacement 图会在 C++ 侧拷贝并完成重连)543 - `replacement``ge.graph.Graph`(replacement 图会在 C++ 侧拷贝并完成重连)
544+- `SubgraphRewriter.replace(boundary, replacement, context=context)` - 自动执行可融合检查、子图替换和融合结果上报;成功返回 `None`,失败抛出 `RuntimeError`
544 545 
545##### 5. Pattern / NodeIo / PatternMatcherConfig546##### 5. Pattern / NodeIo / PatternMatcherConfig
546 547 
@@ -564,10 +565,11 @@ run 包可携带多个 `ge_py_pass_bridge` native 子 wheel,但安装脚本只
564 565 
565**文件位置**: `graph_fuse_inspector_binding.cc``fuse_inspector.py`566**文件位置**: `graph_fuse_inspector_binding.cc``fuse_inspector.py`
566 567 
567-**功能**: 为 graph base 类 pass 提供改图前的融合可行性检查。568+**功能**: 为 graph base 类 pass 提供改图前的融合可行性检查和改图后的融合结果上报
568 569 
569**主要接口**:570**主要接口**:
570- `can_fuse(nodes: Iterable[Node]) -> FuseCheckResult` - 检查节点集合融合成单节点后是否满足 stream label 和无环约束571- `can_fuse(nodes: Iterable[Node]) -> FuseCheckResult` - 检查节点集合融合成单节点后是否满足 stream label 和无环约束
572+- `report_fuse(nodes_before, nodes_after, context) -> None` - 在自定义改图完成后、旧节点删除前上报融合结果
571- `FuseCheckResult.ok` - 是否可融合573- `FuseCheckResult.ok` - 是否可融合
572- `FuseCheckResult.reason` - 不可融合原因;可融合时为空字符串574- `FuseCheckResult.reason` - 不可融合原因;可融合时为空字符串
573 575 
@@ -575,6 +577,8 @@ native binding 将 Python `Node` iterable 转换为 `std::vector<GNode>`,调
575`GraphFuseInspectorUtils::CanFuse`,再由 `fuse_inspector.py` 将 native 返回的 `(bool, str)` 包装为不可变577`GraphFuseInspectorUtils::CanFuse`,再由 `fuse_inspector.py` 将 native 返回的 `(bool, str)` 包装为不可变
576dataclass。578dataclass。
577业务不可融合返回 `FuseCheckResult(False, reason)`;输入类型错误或 Node handle 失效时抛出 Python 异常。579业务不可融合返回 `FuseCheckResult(False, reason)`;输入类型错误或 Node handle 失效时抛出 Python 异常。
580+`report_fuse` 无需适配返回值,由 `fuse_inspector.py` 直接重导出 native 实现;失败时设置 context 错误信息并
581+抛出 `RuntimeError`,其中空 `nodes_after` 表示只删除旧节点。
578 582 
579##### 6. FusionBasePass 类583##### 6. FusionBasePass 类
580 584 
@@ -358,6 +358,9 @@ PassPluginLoader / ge_compiler.so
358- `PatternMatcherConfigBuilder`358- `PatternMatcherConfigBuilder`
359- `create_pattern`359- `create_pattern`
360- `create_replacement`360- `create_replacement`
361+- `FuseCheckResult`
362+- `can_fuse`
363+- `report_fuse`
361- `load_pass_plugins`364- `load_pass_plugins`
362- `get_registered_passes`365- `get_registered_passes`
363 366 
@@ -4,7 +4,7 @@
4 4 
5- 扫描 `ConcatV2 -> Relu` 结构5- 扫描 `ConcatV2 -> Relu` 结构
6- 构造 replacement 子图(将 Relu 前移到 Concat 的每个输入上)6- 构造 replacement 子图(将 Relu 前移到 Concat 的每个输入上)
7-- 使用 `SubgraphBoundary` + `SubgraphRewriter.replace()` 做子图替换7+- 使用 `SubgraphBoundary` + `SubgraphRewriter.replace(..., context=context)` 做子图替换,并自动完成可融合检查和融合结果上报
8 8 
9> **FusionBasePass与PatternFusionPass**9> **FusionBasePass与PatternFusionPass**
10>10>
@@ -4,7 +4,7 @@ This directory provides a **pure Python** version example of `graph_base_pass/2_
4 4 
5- Scan `ConcatV2 -> Relu` structure5- Scan `ConcatV2 -> Relu` structure
6- Build replacement subgraph (move Relu to each input of Concat)6- Build replacement subgraph (move Relu to each input of Concat)
7-- Use `SubgraphBoundary` + `SubgraphRewriter.replace()` for subgraph replacement7+- Use `SubgraphBoundary` + `SubgraphRewriter.replace(..., context=context)` for replacement with automatic fusion inspection and reporting
8 8 
9> **FusionBasePass vs PatternFusionPass**9> **FusionBasePass vs PatternFusionPass**
10>10>
@@ -18,6 +18,7 @@ from ge.es import GraphBuilder, TensorHolder
18from ge.graph import Graph, Node18from ge.graph import Graph, Node
19from ge.passes import (19from ge.passes import (
20 FusionBasePass,20 FusionBasePass,
21+ PassContext,
21 PassStage,22 PassStage,
22 SubgraphBoundary,23 SubgraphBoundary,
23 SubgraphInput,24 SubgraphInput,
@@ -107,9 +108,11 @@ def _build_boundary(concat_node: Node) -> SubgraphBoundary:
107 return boundary108 return boundary
108 109 
109 110 
110-@register_fusion_pass(name="PythonMoveReluBeforeConcatPass", stage=PassStage.BEFORE_INFER_SHAPE)111+@register_fusion_pass(
112+ name="PythonMoveReluBeforeConcatPass", stage=PassStage.BEFORE_INFER_SHAPE
113+)
111class PythonMoveReluBeforeConcatPass(FusionBasePass):114class PythonMoveReluBeforeConcatPass(FusionBasePass):
112- def run(self, graph: Graph, context) -> bool:115+ def run(self, graph: Graph, context: PassContext) -> bool:
113 print("PythonMoveReluBeforeConcatPass")116 print("PythonMoveReluBeforeConcatPass")
114 117 
115 concat_nodes = _find_concat_nodes(graph)118 concat_nodes = _find_concat_nodes(graph)
@@ -119,9 +122,7 @@ class PythonMoveReluBeforeConcatPass(FusionBasePass):
119 for concat_node in concat_nodes:122 for concat_node in concat_nodes:
120 replacement = _build_replacement_graph(concat_node)123 replacement = _build_replacement_graph(concat_node)
121 boundary = _build_boundary(concat_node)124 boundary = _build_boundary(concat_node)
122- ret = SubgraphRewriter.replace(boundary, replacement)125+ SubgraphRewriter.replace(boundary, replacement, context=context)
123- if ret != 0:
124- raise RuntimeError(f"SubgraphRewriter.replace failed, ret={ret}")
125 print("Replacement of PythonMoveReluBeforeConcatPass succeeded")126 print("Replacement of PythonMoveReluBeforeConcatPass succeeded")
126 return True127 return True
127 128 
@@ -129,4 +130,6 @@ class PythonMoveReluBeforeConcatPass(FusionBasePass):
129if __name__ == "__main__":130if __name__ == "__main__":
130 print("PythonMoveReluBeforeConcatPass 已注册。")131 print("PythonMoveReluBeforeConcatPass 已注册。")
131 print("请通过 ASCEND_GE_PY_PASS_PATH 指向本文件,例如:")132 print("请通过 ASCEND_GE_PY_PASS_PATH 指向本文件,例如:")
132- print(" export ASCEND_GE_PY_PASS_PATH=$PWD/python/src/python_move_relu_before_concat_pass.py")133+ print(
134+ " export ASCEND_GE_PY_PASS_PATH=$PWD/python/src/python_move_relu_before_concat_pass.py"
135+ )
@@ -4,9 +4,11 @@
4 4 
5- 遍历图中 `Conv2D` / `Conv2DV2`,筛选 `data_format == NCHW` 的节点;5- 遍历图中 `Conv2D` / `Conv2DV2`,筛选 `data_format == NCHW` 的节点;
6-`data_format` 改为 `NHWC`6-`data_format` 改为 `NHWC`
7-- 从卷积输出做 BFS,按顺序匹配 `perm == [0,2,3,1]` 与 `[0,3,1,2]` 的 `Transpose`,删除对应 `Transpose` 与 perm 常量产点并重连数据边。7+- 从卷积输出做 BFS,按顺序匹配 `perm == [0,2,3,1]` 与 `[0,3,1,2]` 的 `Transpose`
8+- 改图前调用`can_fuse`,完成边重连后、删除旧节点前调用`report_fuse`
9+- 删除对应`Transpose`与perm常量节点。
8 10 
9-本样例继承 `FusionBasePass` 并重写 `run()`,通过 `Graph.remove_edge` / `add_data_edge` / `remove_node` 与 `Node.set_attr` 完成改写,**不使用** `SubgraphRewriter`。11+本样例继承 `FusionBasePass` 并重写 `run()`,通过 `Graph.remove_edge` / `add_data_edge` / `remove_node` 与 `Node.set_attr` 完成改写,**不使用** `SubgraphRewriter`,因此显式调用`can_fuse`和`report_fuse`
10 12 
11## 与 C++ 版本的差异13## 与 C++ 版本的差异
12 14 
@@ -4,9 +4,11 @@ This directory provides a **pure Python** version example of `graph_base_pass/3_
4 4 
5- Traverse `Conv2D` / `Conv2DV2` in graph, filter nodes with `data_format == NCHW`;5- Traverse `Conv2D` / `Conv2DV2` in graph, filter nodes with `data_format == NCHW`;
6- Change `data_format` to `NHWC`;6- Change `data_format` to `NHWC`;
7-- BFS from Conv output, match `Transpose` with `perm == [0,2,3,1]` and `[0,3,1,2]` in order, delete corresponding `Transpose` and perm constant output nodes and reconnect data edges.7+- BFS from the Conv output and match `Transpose` nodes with `perm == [0,2,3,1]` and `[0,3,1,2]` in order;
8+- Call `can_fuse` before the rewrite, then call `report_fuse` after reconnecting edges and before deleting old nodes;
9+- Delete the matching `Transpose` and perm constant nodes.
8 10 
9-This example inherits `FusionBasePass` and overrides `run()`, completes modification via `Graph.remove_edge` / `add_data_edge` / `remove_node` and `Node.set_attr`, **does not use** `SubgraphRewriter`.11+This example inherits `FusionBasePass` and overrides `run()`. It performs the rewrite through `Graph.remove_edge` / `add_data_edge` / `remove_node` and `Node.set_attr`. Because it **does not use** `SubgraphRewriter`, it calls `can_fuse` and `report_fuse` explicitly.
10 12 
11## Differences from C++ Version13## Differences from C++ Version
12 14 
@@ -18,7 +18,14 @@ from collections import deque
18 18 
19from ge.graph import Graph, Node19from ge.graph import Graph, Node
20from ge.graph.types import DataType20from ge.graph.types import DataType
21-from ge.passes import FusionBasePass, PassStage, register_fusion_pass21+from ge.passes import (
22+ FusionBasePass,
23+ PassContext,
24+ PassStage,
25+ can_fuse,
26+ register_fusion_pass,
27+ report_fuse,
28+)
22 29 
23TARGET_TYPES = frozenset({"Conv2D", "Conv2DV2"})30TARGET_TYPES = frozenset({"Conv2D", "Conv2DV2"})
24PERM0 = [0, 2, 3, 1]31PERM0 = [0, 2, 3, 1]
@@ -78,10 +85,24 @@ def _judge_transpose_perm(perm: list[int], cnt_holder: list[int]) -> bool:
78 return False85 return False
79 86 
80 87 
81-def _remove_transpose_and_relink(graph: Graph, transpose_node: Node) -> bool:88+def _remove_transpose_and_relink(
82- data_node, data_out_idx = transpose_node.get_in_data_nodes_and_port_indexes(DATA_IDX)89+ graph: Graph, transpose_node: Node, context: PassContext
83- perm_node, perm_out_idx = transpose_node.get_in_data_nodes_and_port_indexes(PERM_IDX)90+) -> bool:
84- consumers = list(transpose_node.get_out_data_nodes_and_port_indexes(TRANSPOSE_OUT_IDX))91+ result = can_fuse([transpose_node])
92+ if not result.ok:
93+ context.set_error_message(result.reason)
94+ print(f"RemoveTransposeAndRelink can_fuse check failed: {result.reason}")
95+ return False
96+ 
97+ data_node, data_out_idx = transpose_node.get_in_data_nodes_and_port_indexes(
98+ DATA_IDX
99+ )
100+ perm_node, perm_out_idx = transpose_node.get_in_data_nodes_and_port_indexes(
101+ PERM_IDX
102+ )
103+ consumers = list(
104+ transpose_node.get_out_data_nodes_and_port_indexes(TRANSPOSE_OUT_IDX)
105+ )
85 try:106 try:
86 graph.remove_edge(data_node, data_out_idx, transpose_node, DATA_IDX)107 graph.remove_edge(data_node, data_out_idx, transpose_node, DATA_IDX)
87 graph.remove_edge(perm_node, perm_out_idx, transpose_node, PERM_IDX)108 graph.remove_edge(perm_node, perm_out_idx, transpose_node, PERM_IDX)
@@ -97,6 +118,7 @@ def _remove_transpose_and_relink(graph: Graph, transpose_node: Node) -> bool:
97 except RuntimeError:118 except RuntimeError:
98 return False119 return False
99 print("Remove output edges success")120 print("Remove output edges success")
121+ report_fuse([transpose_node], [], context)
100 try:122 try:
101 graph.remove_node(perm_node)123 graph.remove_node(perm_node)
102 graph.remove_node(transpose_node)124 graph.remove_node(transpose_node)
@@ -105,7 +127,9 @@ def _remove_transpose_and_relink(graph: Graph, transpose_node: Node) -> bool:
105 return True127 return True
106 128 
107 129 
108-def _delete_transpose_pair_behind_if_exist(graph: Graph, conv_node: Node) -> bool:130+def _delete_transpose_pair_behind_if_exist(
131+ graph: Graph, conv_node: Node, context: PassContext
132+) -> bool:
109 queue: deque[Node] = deque()133 queue: deque[Node] = deque()
110 transpose_cnt_holder = [0]134 transpose_cnt_holder = [0]
111 out_sz = conv_node.get_outputs_size()135 out_sz = conv_node.get_outputs_size()
@@ -125,7 +149,7 @@ def _delete_transpose_pair_behind_if_exist(graph: Graph, conv_node: Node) -> boo
125 continue149 continue
126 if not _judge_transpose_perm(perm_list, transpose_cnt_holder):150 if not _judge_transpose_perm(perm_list, transpose_cnt_holder):
127 continue151 continue
128- if not _remove_transpose_and_relink(graph, node_ptr):152+ if not _remove_transpose_and_relink(graph, node_ptr, context):
129 print("RemoveTransposeAndRelink failed")153 print("RemoveTransposeAndRelink failed")
130 return False154 return False
131 if transpose_cnt_holder[0] == 2:155 if transpose_cnt_holder[0] == 2:
@@ -147,9 +171,11 @@ def _find_nchw_conv_nodes(graph: Graph) -> list[Node]:
147 return conv_nodes171 return conv_nodes
148 172 
149 173 
150-@register_fusion_pass(name="PythonConvTransFormatPass", stage=PassStage.BEFORE_INFER_SHAPE)174+@register_fusion_pass(
175+ name="PythonConvTransFormatPass", stage=PassStage.BEFORE_INFER_SHAPE
176+)
151class PythonConvTransFormatPass(FusionBasePass):177class PythonConvTransFormatPass(FusionBasePass):
152- def run(self, graph: Graph, context) -> bool:178+ def run(self, graph: Graph, context: PassContext) -> bool:
153 print("PythonConvTransFormatPass is starting")179 print("PythonConvTransFormatPass is starting")
154 conv_nodes = _find_nchw_conv_nodes(graph)180 conv_nodes = _find_nchw_conv_nodes(graph)
155 if not conv_nodes:181 if not conv_nodes:
@@ -161,9 +187,13 @@ class PythonConvTransFormatPass(FusionBasePass):
161 except RuntimeError:187 except RuntimeError:
162 print("Modify format of node failed")188 print("Modify format of node failed")
163 raise189 raise
164- if not _delete_transpose_pair_behind_if_exist(graph, node):190+ if not _delete_transpose_pair_behind_if_exist(graph, node, context):
165 print("DeleteTransposePairBehindIfExist failed")191 print("DeleteTransposePairBehindIfExist failed")
166- raise RuntimeError("DeleteTransposePairBehindIfExist failed")192+ error_message = context.get_error_message()
193+ failure_message = "DeleteTransposePairBehindIfExist failed"
194+ if error_message:
195+ failure_message += f": {error_message}"
196+ raise RuntimeError(failure_message)
167 print("PythonConvTransFormatPass completed")197 print("PythonConvTransFormatPass completed")
168 return True198 return True
169 199 
@@ -171,4 +201,6 @@ class PythonConvTransFormatPass(FusionBasePass):
171if __name__ == "__main__":201if __name__ == "__main__":
172 print("PythonConvTransFormatPass 已注册。")202 print("PythonConvTransFormatPass 已注册。")
173 print("请通过 ASCEND_GE_PY_PASS_PATH 指向本文件,例如:")203 print("请通过 ASCEND_GE_PY_PASS_PATH 指向本文件,例如:")
174- print(" export ASCEND_GE_PY_PASS_PATH=$PWD/python/src/python_modify_conv_data_format_pass.py")204+ print(
205+ " export ASCEND_GE_PY_PASS_PATH=$PWD/python/src/python_modify_conv_data_format_pass.py"
206+ )
@@ -186,6 +186,11 @@ const std::string &GetSharedPybindMarkerFilePath() {
186 return path;186 return path;
187}187}
188 188 
189+const std::string &GetSharedPybindReportFuseMarkerFilePath() {
190+ static const std::string path = GetSharedPybindPassDir().CreateFilePath("report_fuse_success_marker.txt");
191+ return path;
192+}
193+ 
189const std::string &GetSharedPybindPatternPassFilePath() {194const std::string &GetSharedPybindPatternPassFilePath() {
190 static const std::string path = GetSharedPybindPassDir().CreateFilePath("pybind_pattern_passes.py");195 static const std::string path = GetSharedPybindPassDir().CreateFilePath("pybind_pattern_passes.py");
191 return path;196 return path;
@@ -222,14 +227,24 @@ void EnsureSharedPybindPassFile() {
222 std::ostringstream pass_code;227 std::ostringstream pass_code;
223 pass_code << "from pathlib import Path\n"228 pass_code << "from pathlib import Path\n"
224 << "from ge.graph import Graph\n"229 << "from ge.graph import Graph\n"
225- << "from ge.passes import FusionBasePass, PassStage, register_fusion_pass, PassContext\n\n"230+ << "from ge.passes import (\n"
231+ << " FusionBasePass, PassStage, register_fusion_pass, report_fuse\n"
232+ << ")\n\n"
226 << "MARKER_FILE = r'" << GetSharedPybindMarkerFilePath() << "'\n\n"233 << "MARKER_FILE = r'" << GetSharedPybindMarkerFilePath() << "'\n\n"
234+ << "REPORT_FUSE_MARKER_FILE = r'" << GetSharedPybindReportFuseMarkerFilePath() << "'\n\n"
227 << "@register_fusion_pass(name='PythonPybindBridgePass', stage=PassStage.AFTER_INFER_SHAPE)\n"235 << "@register_fusion_pass(name='PythonPybindBridgePass', stage=PassStage.AFTER_INFER_SHAPE)\n"
228 << "class PythonPybindBridgePass(FusionBasePass):\n"236 << "class PythonPybindBridgePass(FusionBasePass):\n"
229 << " def run(self, graph, context):\n"237 << " def run(self, graph, context):\n"
230 << " assert isinstance(graph, Graph)\n"238 << " assert isinstance(graph, Graph)\n"
231 << " Path(MARKER_FILE).write_text(f\"{graph.name}|{context.get_pass_name()}\", encoding='utf-8')\n"239 << " Path(MARKER_FILE).write_text(f\"{graph.name}|{context.get_pass_name()}\", encoding='utf-8')\n"
232 << " return 0\n\n"240 << " return 0\n\n"
241+ << "@register_fusion_pass(name='PythonPybindReportFusePass', stage=PassStage.AFTER_INFER_SHAPE)\n"
242+ << "class PythonPybindReportFusePass(FusionBasePass):\n"
243+ << " def run(self, graph, context):\n"
244+ << " nodes = graph.get_direct_nodes()\n"
245+ << " report_fuse(nodes, [], context)\n"
246+ << " Path(REPORT_FUSE_MARKER_FILE).write_text(context.get_pass_name(), encoding='utf-8')\n"
247+ << " return 0\n\n"
233 << "@register_fusion_pass(name='PythonPybindBridgeFailedPass', "248 << "@register_fusion_pass(name='PythonPybindBridgeFailedPass', "
234 "stage=PassStage.AFTER_BUILTIN_FUSION_PASS)\n"249 "stage=PassStage.AFTER_BUILTIN_FUSION_PASS)\n"
235 << "class PythonPybindBridgeFailedPass(FusionBasePass):\n"250 << "class PythonPybindBridgeFailedPass(FusionBasePass):\n"
@@ -1686,7 +1701,9 @@ TEST_F(UtestFusionPassExecutor, PythonPassBridgeCApi_LoadsConfiguredNativeModule
1686TEST_F(UtestFusionPassExecutor, PythonFusionBasePass_PybindBridge_RunSuccess) {1701TEST_F(UtestFusionPassExecutor, PythonFusionBasePass_PybindBridge_RunSuccess) {
1687 EnsureSharedPybindPassFile();1702 EnsureSharedPybindPassFile();
1688 const auto &marker_file = GetSharedPybindMarkerFilePath();1703 const auto &marker_file = GetSharedPybindMarkerFilePath();
1704+ const auto &report_fuse_marker_file = GetSharedPybindReportFuseMarkerFilePath();
1689 (void)remove(marker_file.c_str());1705 (void)remove(marker_file.c_str());
1706+ (void)remove(report_fuse_marker_file.c_str());
1690 1707 
1691 ScopedEnvVar scoped_py_pass_path(kEnvPythonPassPath, GetSharedPybindPassFilePath());1708 ScopedEnvVar scoped_py_pass_path(kEnvPythonPassPath, GetSharedPybindPassFilePath());
1692 ASSERT_EQ(RegisterPythonPassesFromPlugin(), SUCCESS);1709 ASSERT_EQ(RegisterPythonPassesFromPlugin(), SUCCESS);
@@ -1698,6 +1715,7 @@ TEST_F(UtestFusionPassExecutor, PythonFusionBasePass_PybindBridge_RunSuccess) {
1698 EXPECT_EQ(pass_executor.RunPasses(target_compute_graph, CustomPassStage::kAfterInferShape), SUCCESS);1715 EXPECT_EQ(pass_executor.RunPasses(target_compute_graph, CustomPassStage::kAfterInferShape), SUCCESS);
1699 EXPECT_EQ(pass_executor.RunPasses(target_compute_graph, CustomPassStage::kAfterInferShape), SUCCESS);1716 EXPECT_EQ(pass_executor.RunPasses(target_compute_graph, CustomPassStage::kAfterInferShape), SUCCESS);
1700 EXPECT_EQ(ReadFile(marker_file), expected_graph_name + "|PythonPybindBridgePass");1717 EXPECT_EQ(ReadFile(marker_file), expected_graph_name + "|PythonPybindBridgePass");
1718+ EXPECT_EQ(ReadFile(report_fuse_marker_file), "PythonPybindReportFusePass");
1701}1719}
1702 1720 
1703TEST_F(UtestFusionPassExecutor, PythonFusionBasePass_PybindBridge_RunFailedOnPythonException) {1721TEST_F(UtestFusionPassExecutor, PythonFusionBasePass_PybindBridge_RunFailedOnPythonException) {
@@ -81,8 +81,3 @@ def test_can_fuse_returns_reason_for_nodes_from_different_graphs():
81 assert graph2 is not None81 assert graph2 is not None
82 assert result.ok is False82 assert result.ok is False
83 assert "different graphs" in result.reason83 assert "different graphs" in result.reason
84- 
85- 
86-def test_can_fuse_rejects_non_node_elements():
87- with pytest.raises(TypeError, match="ge.graph.Node"):
88- can_fuse([object()])