Skip to content

Commit ea0afef

Browse files
Fix local run qdq op. (#377)
#182
1 parent e2ca152 commit ea0afef

2 files changed

Lines changed: 294 additions & 6 deletions

File tree

src/winml/modelkit/analyze/core/runtime_checker_query.py

Lines changed: 243 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1141,19 +1141,23 @@ def __init__(
11411141
dynamic axis indices.
11421142
"""
11431143
self.dynamic_axis_strict_mode = dynamic_axis_strict_mode
1144+
self.model_proto: onnx.ModelProto = model_proto
11441145
self.model_path = Path(model_path) if model_path is not None else None
11451146
# Try shape inference: standard ONNX first, then symbolic (onnxruntime)
11461147
try:
11471148
# Standard ONNX shape inference — uses temp file for models
11481149
# with external data (avoids silent empty-graph result).
1149-
self.model_proto = infer_onnx_shapes(model_proto)
1150+
inferred_model = infer_onnx_shapes(model_proto)
1151+
self.model_proto = inferred_model if inferred_model is not None else model_proto
11501152

11511153
# Then try to enhance with symbolic shape inference
11521154
# if available which supports Microsoft domain
11531155
try:
11541156
from onnxruntime.tools.symbolic_shape_infer import SymbolicShapeInference
11551157

1156-
self.model_proto = SymbolicShapeInference.infer_shapes(self.model_proto)
1158+
symbolic_model = SymbolicShapeInference.infer_shapes(self.model_proto)
1159+
if symbolic_model is not None:
1160+
self.model_proto = symbolic_model
11571161
except Exception as e:
11581162
# If symbolic shape inference fails, continue with standard inference result
11591163
logger.debug(
@@ -1357,6 +1361,196 @@ def _get_ep_checker(self) -> EPChecker:
13571361
)
13581362
return self._ep_checker
13591363

1364+
@staticmethod
1365+
def _clone_node_proto(node: onnx.NodeProto) -> onnx.NodeProto:
1366+
"""Clone a node proto so extracted test models do not reuse graph objects."""
1367+
cloned = onnx.NodeProto()
1368+
cloned.CopyFrom(node)
1369+
return cloned
1370+
1371+
def _find_producer_node(self, tensor_name: str) -> onnx.NodeProto | None:
1372+
"""Return the node that produces a tensor, if any."""
1373+
if not tensor_name:
1374+
return None
1375+
1376+
for candidate in self.model_proto.graph.node:
1377+
if tensor_name in candidate.output:
1378+
return candidate
1379+
return None
1380+
1381+
def _find_consumer_nodes(self, tensor_name: str) -> list[onnx.NodeProto]:
1382+
"""Return nodes that consume a tensor."""
1383+
if not tensor_name:
1384+
return []
1385+
1386+
return [
1387+
candidate for candidate in self.model_proto.graph.node if tensor_name in candidate.input
1388+
]
1389+
1390+
def _build_opset_imports(
1391+
self,
1392+
nodes: list[onnx.NodeProto],
1393+
fallback_op_domain: ONNXDomain,
1394+
fallback_opset_version: int,
1395+
) -> list[onnx.OperatorSetIdProto]:
1396+
"""Build opset imports for an extracted runtime-test model."""
1397+
opset_imports: list[onnx.OperatorSetIdProto] = []
1398+
added_domains: set[str] = set()
1399+
saw_non_default_domain = False
1400+
1401+
def add_domain(domain_str: str, version: int) -> None:
1402+
canonical_domain = "" if domain_str in {"", ONNXDomain.AI_ONNX.value} else domain_str
1403+
if canonical_domain in added_domains:
1404+
return
1405+
1406+
added_domains.add(canonical_domain)
1407+
effective_version = max(version, 7) if canonical_domain == "" else version
1408+
opset_imports.append(onnx.helper.make_opsetid(canonical_domain, effective_version))
1409+
1410+
for included_node in nodes:
1411+
raw_domain = included_node.domain or ""
1412+
try:
1413+
node_domain = ONNXDomain.from_str(raw_domain)
1414+
add_domain(node_domain.schema_domain, self.opset_versions.get(node_domain, 1))
1415+
saw_non_default_domain = saw_non_default_domain or node_domain != ONNXDomain.AI_ONNX
1416+
except ValueError:
1417+
add_domain(raw_domain, 1)
1418+
saw_non_default_domain = saw_non_default_domain or bool(raw_domain)
1419+
1420+
if not opset_imports:
1421+
add_domain(fallback_op_domain.schema_domain, fallback_opset_version)
1422+
saw_non_default_domain = fallback_op_domain != ONNXDomain.AI_ONNX
1423+
1424+
if saw_non_default_domain and "" not in added_domains:
1425+
default_opset = self.opset_versions.get(ONNXDomain.AI_ONNX, 17)
1426+
add_domain("", default_opset)
1427+
1428+
return opset_imports
1429+
1430+
def _build_runtime_test_model(
1431+
self,
1432+
node: onnx.NodeProto,
1433+
op_domain: ONNXDomain,
1434+
opset_version: int,
1435+
include_adjacent_qdq: bool = False,
1436+
) -> onnx.ModelProto:
1437+
"""Build the model used for local EP fallback and failed-node artifacts.
1438+
1439+
For QDQ operators, include the adjacent DequantizeLinear and QuantizeLinear
1440+
nodes so the local test model preserves the same quantized context.
1441+
"""
1442+
if not include_adjacent_qdq:
1443+
return self._build_single_node_model(node, op_domain, opset_version)
1444+
1445+
graph_inputs: list[onnx.ValueInfoProto] = []
1446+
graph_initializers: list[onnx.TensorProto] = []
1447+
graph_outputs: list[onnx.ValueInfoProto] = []
1448+
pre_nodes: list[onnx.NodeProto] = []
1449+
post_nodes: list[onnx.NodeProto] = []
1450+
seen_inputs: set[str] = set()
1451+
seen_initializers: set[str] = set()
1452+
seen_outputs: set[str] = set()
1453+
seen_pre_nodes: set[str] = set()
1454+
seen_post_nodes: set[str] = set()
1455+
1456+
def add_graph_source(name: str) -> None:
1457+
if not name:
1458+
return
1459+
1460+
if name in self.initializers:
1461+
if name not in seen_initializers:
1462+
graph_initializers.append(self.initializers[name])
1463+
seen_initializers.add(name)
1464+
return
1465+
1466+
if name in self.constants:
1467+
if name not in seen_initializers:
1468+
graph_initializers.append(self.constants[name])
1469+
seen_initializers.add(name)
1470+
return
1471+
1472+
vi = self.valueinfo.get(name)
1473+
if vi is None:
1474+
raise ValueError(f"Tensor '{name}' not found in valueinfo or initializers")
1475+
if name not in seen_inputs:
1476+
graph_inputs.append(vi)
1477+
seen_inputs.add(name)
1478+
1479+
def add_graph_output(name: str) -> None:
1480+
if not name or name in seen_outputs:
1481+
return
1482+
1483+
vi = self.valueinfo.get(name)
1484+
if vi is not None:
1485+
graph_outputs.append(vi)
1486+
else:
1487+
graph_outputs.append(
1488+
onnx.helper.make_tensor_value_info(name, onnx.TensorProto.UNDEFINED, None)
1489+
)
1490+
seen_outputs.add(name)
1491+
1492+
for inp_name in node.input:
1493+
if not inp_name:
1494+
continue
1495+
1496+
producer = self._find_producer_node(inp_name)
1497+
if producer is not None and producer.op_type == "DequantizeLinear":
1498+
producer_key = producer.name or "|".join(producer.output)
1499+
if producer_key not in seen_pre_nodes:
1500+
pre_nodes.append(self._clone_node_proto(producer))
1501+
seen_pre_nodes.add(producer_key)
1502+
for producer_input in producer.input:
1503+
add_graph_source(producer_input)
1504+
continue
1505+
1506+
add_graph_source(inp_name)
1507+
1508+
for out_name in node.output:
1509+
if not out_name:
1510+
continue
1511+
1512+
quantize_consumers = [
1513+
consumer
1514+
for consumer in self._find_consumer_nodes(out_name)
1515+
if consumer.op_type == "QuantizeLinear"
1516+
and consumer.input
1517+
and consumer.input[0] == out_name
1518+
]
1519+
if quantize_consumers:
1520+
for consumer in quantize_consumers:
1521+
consumer_key = consumer.name or "|".join(consumer.output)
1522+
if consumer_key not in seen_post_nodes:
1523+
post_nodes.append(self._clone_node_proto(consumer))
1524+
seen_post_nodes.add(consumer_key)
1525+
for consumer_input in consumer.input[1:]:
1526+
add_graph_source(consumer_input)
1527+
for consumer_output in consumer.output:
1528+
add_graph_output(consumer_output)
1529+
continue
1530+
1531+
add_graph_output(out_name)
1532+
1533+
nodes = [*pre_nodes, self._clone_node_proto(node), *post_nodes]
1534+
graph = onnx.helper.make_graph(
1535+
nodes,
1536+
f"runtime_test_{node.op_type}",
1537+
graph_inputs,
1538+
graph_outputs,
1539+
initializer=graph_initializers,
1540+
)
1541+
1542+
model = onnx.helper.make_model(
1543+
graph,
1544+
opset_imports=self._build_opset_imports(nodes, op_domain, opset_version),
1545+
)
1546+
1547+
try:
1548+
model = infer_onnx_shapes(model)
1549+
except Exception as e:
1550+
logger.debug("Shape inference failed for runtime-test model: %s", e)
1551+
1552+
return model
1553+
13601554
def _build_single_node_model(
13611555
self, node: onnx.NodeProto, op_domain: ONNXDomain, opset_version: int
13621556
) -> onnx.ModelProto:
@@ -1454,6 +1648,33 @@ def _build_single_node_model(
14541648

14551649
return model
14561650

1651+
def _generate_model_inputs(self, model: onnx.ModelProto) -> dict[str, np.ndarray]:
1652+
"""Generate dummy input data for a runtime-test model."""
1653+
input_feed: dict[str, np.ndarray] = {}
1654+
default_dim_size = 2 # Replace dynamic/unknown dims with this size
1655+
initializer_names = {initializer.name for initializer in model.graph.initializer}
1656+
1657+
for graph_input in model.graph.input:
1658+
if graph_input.name in initializer_names:
1659+
continue
1660+
1661+
shape, dtype_str = shape_and_dtype_from_valueinfo(graph_input)
1662+
if dtype_str is None:
1663+
raise ValueError(f"Input '{graph_input.name}' has no dtype information")
1664+
1665+
np_dtype = SupportedONNXType.from_annotation(dtype_str).np_type
1666+
1667+
if shape is None:
1668+
concrete_shape = (default_dim_size,)
1669+
else:
1670+
concrete_shape = tuple(
1671+
dim if isinstance(dim, int) and dim > 0 else default_dim_size for dim in shape
1672+
)
1673+
1674+
input_feed[graph_input.name] = np.zeros(concrete_shape, dtype=np_dtype)
1675+
1676+
return input_feed
1677+
14571678
def _generate_node_inputs(self, node: onnx.NodeProto) -> dict[str, np.ndarray]:
14581679
"""Generate dummy input data for a single-node model.
14591680
@@ -1531,6 +1752,7 @@ def _try_local_ep_check(
15311752
pattern_match: PatternMatchResult,
15321753
node_tags: list[NodeTag],
15331754
fallback_reason: str,
1755+
include_adjacent_qdq: bool = False,
15341756
save_node_types: set[str] | None = None,
15351757
conditions: Any | None = None,
15361758
) -> PatternRuntime | None:
@@ -1572,11 +1794,16 @@ def _try_local_ep_check(
15721794
)
15731795

15741796
try:
1575-
model = self._build_single_node_model(node, op_domain, opset_version)
1576-
input_feed = self._generate_node_inputs(node)
1797+
model = self._build_runtime_test_model(
1798+
node,
1799+
op_domain,
1800+
opset_version,
1801+
include_adjacent_qdq=include_adjacent_qdq,
1802+
)
1803+
input_feed = self._generate_model_inputs(model)
15771804
except Exception as e:
15781805
logger.debug(
1579-
"Failed to build single-node model for local EP check on %s (%s): %s",
1806+
"Failed to build runtime-test model for local EP check on %s (%s): %s",
15801807
node.name,
15811808
node.op_type,
15821809
e,
@@ -1808,6 +2035,7 @@ def _maybe_save_failed_node_result(
18082035
opset_version: int,
18092036
result: RuntimeTestResult,
18102037
cache_key: Any,
2038+
include_adjacent_qdq: bool = False,
18112039
save_node_types: set[str] | None = None,
18122040
) -> None:
18132041
"""Save unsupported or partial node models without re-running result computation."""
@@ -1820,7 +2048,12 @@ def _maybe_save_failed_node_result(
18202048
if not (is_unsupported or is_partial):
18212049
return
18222050

1823-
node_model = self._build_single_node_model(node, op_domain, opset_version)
2051+
node_model = self._build_runtime_test_model(
2052+
node,
2053+
op_domain,
2054+
opset_version,
2055+
include_adjacent_qdq=include_adjacent_qdq,
2056+
)
18242057
self._save_failed_node(
18252058
node,
18262059
node_model,
@@ -2079,6 +2312,7 @@ def get_pattern_id(is_qdq):
20792312
pattern_match,
20802313
node_tags,
20812314
fallback_reason,
2315+
include_adjacent_qdq=is_qdq,
20822316
save_node_types=save_node_types,
20832317
# conditions not available when domain/op
20842318
# rules are missing
@@ -2187,6 +2421,7 @@ def get_pattern_id(is_qdq):
21872421
pattern_match,
21882422
node_tags,
21892423
fallback_reason,
2424+
include_adjacent_qdq=is_qdq,
21902425
conditions=cache_key,
21912426
)
21922427
if local_result is not None:
@@ -2223,6 +2458,7 @@ def get_pattern_id(is_qdq):
22232458
pattern_match,
22242459
node_tags,
22252460
fallback_reason,
2461+
include_adjacent_qdq=is_qdq,
22262462
conditions=None,
22272463
)
22282464
if local_result is not None:
@@ -2335,6 +2571,7 @@ def get_pattern_id(is_qdq):
23352571
opset_version,
23362572
result,
23372573
cache_key,
2574+
include_adjacent_qdq=is_qdq,
23382575
save_node_types=save_node_types,
23392576
)
23402577

0 commit comments

Comments
 (0)