Skip to content

Commit 9e394da

Browse files
authored
Duplicate cat if sandwiched between deq/quant (#19925)
Differential Revision: D107174424 Pull Request resolved: #19925
1 parent beb9660 commit 9e394da

2 files changed

Lines changed: 315 additions & 0 deletions

File tree

backends/cadence/aot/reorder_ops.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,6 +895,77 @@ def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool:
895895
return should_swap(parent, node) and do_swap(parent, node)
896896

897897

898+
_QUANT_OVERLOAD_PACKETS = {
899+
exir_ops.edge.quantized_decomposed.quantize_per_tensor,
900+
exir_ops.edge.cadence.quantize_per_tensor,
901+
}
902+
903+
_DEQUANT_OVERLOAD_PACKETS = {
904+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor,
905+
exir_ops.edge.cadence.dequantize_per_tensor,
906+
}
907+
908+
909+
@register_cadence_pass(CadencePassAttribute(opt_level=1))
910+
class SplitDequantizedCatPass(RemoveOrReplacePassInterface):
911+
"""Split a cat node so that quantize consumers get their own copy.
912+
913+
Fires when a cat has all floating-point inputs, at least one dequantize
914+
input, and at least one quantize consumer. Quant consumers are grouped
915+
by matching qparams; each group receives a dedicated duplicate of the
916+
cat node. Non-quant consumers stay on the original cat, whose
917+
semantics are unchanged.
918+
919+
A later pass (e.g. AdvanceQuantizeOpAboveDefChainPass extended for cat)
920+
can then hoist each quant above its single-consumer cat copy without
921+
affecting the non-quant paths.
922+
"""
923+
924+
@property
925+
def targets(self) -> list[EdgeOpOverload]:
926+
return [exir_ops.edge.aten.cat.default]
927+
928+
def maybe_remove_or_replace(self, node: torch.fx.Node) -> bool:
929+
cat_inputs = node.args[0]
930+
if not isinstance(cat_inputs, (list, tuple)):
931+
return False
932+
933+
has_dequant_input = False
934+
for inp in cat_inputs:
935+
assert isinstance(inp, torch.fx.Node)
936+
val = inp.meta["val"]
937+
if val is None or not val.is_floating_point():
938+
return False
939+
if get_overload_packet(inp.target) in _DEQUANT_OVERLOAD_PACKETS:
940+
has_dequant_input = True
941+
942+
if not has_dequant_input:
943+
return False
944+
945+
quant_groups: DefaultDict[Tuple, List[torch.fx.Node]] = defaultdict(list)
946+
for user in list(node.users.keys()):
947+
if get_overload_packet(user.target) in _QUANT_OVERLOAD_PACKETS:
948+
quant_groups[user.args[1:]].append(user)
949+
950+
if not quant_groups:
951+
return False
952+
953+
graph = node.graph
954+
dim = get_arg(node, "dim", int)
955+
for quant_consumers in quant_groups.values():
956+
with graph.inserting_after(node):
957+
dup_cat = graph.call_function(
958+
exir_ops.edge.aten.cat.default,
959+
args=(list(cat_inputs), dim),
960+
)
961+
dup_cat.meta = node.meta.copy()
962+
963+
for q_node in quant_consumers:
964+
q_node.replace_input_with(node, dup_cat)
965+
966+
return True
967+
968+
898969
# The following class consolidates functions to reoder ops (i.e., either hoist
899970
# or sink some ops in the graph).
900971
class CadenceReorderOpsInGraph:

backends/cadence/aot/tests/test_reorder_ops_passes.py

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
PostponePermuteOpBelowSqueezeOrUnsqueezeLikeView,
2929
PropagateSlice,
3030
SinkOpsCloserToUsePass,
31+
SplitDequantizedCatPass,
3132
)
3233
from executorch.backends.test.graph_builder import GraphBuilder
3334
from executorch.exir.dialects._ops import ops as exir_ops
@@ -1024,3 +1025,246 @@ def test_no_swap_binary_same_shape(self) -> None:
10241025
result = PropagateSlice().call(gm)
10251026

10261027
self.assertFalse(result.modified)
1028+
1029+
1030+
class TestSplitDequantizedCat(unittest.TestCase):
1031+
def test_no_dequant_input_noop(self) -> None:
1032+
"""Cat with only float (non-dequant) inputs should not be split."""
1033+
builder = GraphBuilder()
1034+
a = builder.placeholder("a", torch.randn(2, 4))
1035+
b = builder.placeholder("b", torch.randn(2, 4))
1036+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([a, b], 0))
1037+
q = builder.call_operator(
1038+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1039+
args=(cat, 0.01, 0, -128, 127, torch.int8),
1040+
)
1041+
dq = builder.call_operator(
1042+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1043+
args=(q, 0.01, 0, -128, 127, torch.int8),
1044+
)
1045+
builder.output([dq])
1046+
gm = builder.get_graph_module()
1047+
1048+
result = SplitDequantizedCatPass().call(gm)
1049+
1050+
self.assertFalse(result.modified)
1051+
self.assertEqual(count_node(gm, exir_ops.edge.aten.cat.default), 1)
1052+
1053+
def test_no_quant_output_noop(self) -> None:
1054+
"""Cat with a dequant input but no quant consumer should not be split."""
1055+
builder = GraphBuilder()
1056+
x_int8 = builder.placeholder(
1057+
"x_int8", torch.randint(-128, 127, (2, 4), dtype=torch.int8)
1058+
)
1059+
dq = builder.call_operator(
1060+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1061+
args=(x_int8, 0.01, 0, -128, 127, torch.int8),
1062+
)
1063+
b = builder.placeholder("b", torch.randn(2, 4))
1064+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([dq, b], 0))
1065+
builder.output([cat])
1066+
gm = builder.get_graph_module()
1067+
1068+
result = SplitDequantizedCatPass().call(gm)
1069+
1070+
self.assertFalse(result.modified)
1071+
self.assertEqual(count_node(gm, exir_ops.edge.aten.cat.default), 1)
1072+
1073+
def test_one_dequant_input_one_quant_output(self) -> None:
1074+
"""Cat with one dequant input and one quant consumer should be split."""
1075+
builder = GraphBuilder()
1076+
x_int8 = builder.placeholder(
1077+
"x_int8", torch.randint(-128, 127, (2, 4), dtype=torch.int8)
1078+
)
1079+
dq = builder.call_operator(
1080+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1081+
args=(x_int8, 0.01, 0, -128, 127, torch.int8),
1082+
)
1083+
b = builder.placeholder("b", torch.randn(2, 4))
1084+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([dq, b], 0))
1085+
sliced = builder.call_operator(
1086+
exir_ops.edge.aten.slice_copy.Tensor, args=(cat, 0, 0, 2)
1087+
)
1088+
q = builder.call_operator(
1089+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1090+
args=(cat, 0.02, -5, -128, 127, torch.int8),
1091+
)
1092+
q_dq = builder.call_operator(
1093+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1094+
args=(q, 0.02, -5, -128, 127, torch.int8),
1095+
)
1096+
builder.output([sliced, q_dq])
1097+
gm = builder.get_graph_module()
1098+
1099+
result = SplitDequantizedCatPass().call(gm)
1100+
1101+
self.assertTrue(result.modified)
1102+
converted = result.graph_module
1103+
self.assertEqual(count_node(converted, exir_ops.edge.aten.cat.default), 2)
1104+
1105+
# The slice should still be on the original cat, which has no quant consumers.
1106+
slice_nodes = converted.graph.find_nodes(
1107+
op="call_function", target=exir_ops.edge.aten.slice_copy.Tensor
1108+
)
1109+
for node in slice_nodes:
1110+
cat_input = node.args[0]
1111+
self.assertEqual(cat_input.target, exir_ops.edge.aten.cat.default)
1112+
quant_users = [
1113+
u
1114+
for u in cat_input.users
1115+
if u.target
1116+
== exir_ops.edge.quantized_decomposed.quantize_per_tensor.default
1117+
]
1118+
self.assertEqual(len(quant_users), 0)
1119+
1120+
def test_non_quant_consumers_stay_on_original_cat(self) -> None:
1121+
"""All non-quant consumers should remain on the original cat."""
1122+
builder = GraphBuilder()
1123+
x_int8 = builder.placeholder(
1124+
"x_int8", torch.randint(-128, 127, (2, 4), dtype=torch.int8)
1125+
)
1126+
dq = builder.call_operator(
1127+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1128+
args=(x_int8, 0.01, 0, -128, 127, torch.int8),
1129+
)
1130+
b = builder.placeholder("b", torch.randn(2, 4))
1131+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([dq, b], 0))
1132+
sliced = builder.call_operator(
1133+
exir_ops.edge.aten.slice_copy.Tensor, args=(cat, 0, 0, 2)
1134+
)
1135+
abs_val = builder.call_operator(exir_ops.edge.aten.abs.default, args=(cat,))
1136+
q = builder.call_operator(
1137+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1138+
args=(cat, 0.02, -5, -128, 127, torch.int8),
1139+
)
1140+
q_dq = builder.call_operator(
1141+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1142+
args=(q, 0.02, -5, -128, 127, torch.int8),
1143+
)
1144+
builder.output([sliced, abs_val, q_dq])
1145+
gm = builder.get_graph_module()
1146+
1147+
result = SplitDequantizedCatPass().call(gm)
1148+
1149+
self.assertTrue(result.modified)
1150+
converted = result.graph_module
1151+
self.assertEqual(count_node(converted, exir_ops.edge.aten.cat.default), 2)
1152+
1153+
# Both non-quant consumers (slice and abs) should use the same cat.
1154+
slice_nodes = converted.graph.find_nodes(
1155+
op="call_function", target=exir_ops.edge.aten.slice_copy.Tensor
1156+
)
1157+
abs_nodes = converted.graph.find_nodes(
1158+
op="call_function", target=exir_ops.edge.aten.abs.default
1159+
)
1160+
self.assertEqual(len(slice_nodes), 1)
1161+
self.assertEqual(len(abs_nodes), 1)
1162+
self.assertIs(slice_nodes[0].args[0], abs_nodes[0].args[0])
1163+
1164+
# That shared cat should have no quant consumers.
1165+
original_cat = slice_nodes[0].args[0]
1166+
quant_users = [
1167+
u
1168+
for u in original_cat.users
1169+
if u.target
1170+
== exir_ops.edge.quantized_decomposed.quantize_per_tensor.default
1171+
]
1172+
self.assertEqual(len(quant_users), 0)
1173+
1174+
def test_two_quant_outputs_same_params_shared_cat(self) -> None:
1175+
"""Two quant consumers with identical params should share one duplicate cat."""
1176+
builder = GraphBuilder()
1177+
x_int8 = builder.placeholder(
1178+
"x_int8", torch.randint(-128, 127, (2, 4), dtype=torch.int8)
1179+
)
1180+
dq = builder.call_operator(
1181+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1182+
args=(x_int8, 0.01, 0, -128, 127, torch.int8),
1183+
)
1184+
b = builder.placeholder("b", torch.randn(2, 4))
1185+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([dq, b], 0))
1186+
sliced = builder.call_operator(
1187+
exir_ops.edge.aten.slice_copy.Tensor, args=(cat, 0, 0, 2)
1188+
)
1189+
q1 = builder.call_operator(
1190+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1191+
args=(cat, 0.02, -5, -128, 127, torch.int8),
1192+
)
1193+
q2 = builder.call_operator(
1194+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1195+
args=(cat, 0.02, -5, -128, 127, torch.int8),
1196+
)
1197+
dq1 = builder.call_operator(
1198+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1199+
args=(q1, 0.02, -5, -128, 127, torch.int8),
1200+
)
1201+
dq2 = builder.call_operator(
1202+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1203+
args=(q2, 0.02, -5, -128, 127, torch.int8),
1204+
)
1205+
builder.output([sliced, dq1, dq2])
1206+
gm = builder.get_graph_module()
1207+
1208+
result = SplitDequantizedCatPass().call(gm)
1209+
1210+
self.assertTrue(result.modified)
1211+
converted = result.graph_module
1212+
# Original cat + one shared duplicate = 2 cats total
1213+
self.assertEqual(count_node(converted, exir_ops.edge.aten.cat.default), 2)
1214+
1215+
# Both quant nodes should share the same cat input (the duplicate).
1216+
quant_nodes = converted.graph.find_nodes(
1217+
op="call_function",
1218+
target=exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1219+
)
1220+
quant_cat_inputs = {node.args[0] for node in quant_nodes}
1221+
self.assertEqual(len(quant_cat_inputs), 1)
1222+
1223+
def test_two_quant_outputs_different_params_separate_cats(self) -> None:
1224+
"""Two quant consumers with different params should get separate duplicate cats."""
1225+
builder = GraphBuilder()
1226+
x_int8 = builder.placeholder(
1227+
"x_int8", torch.randint(-128, 127, (2, 4), dtype=torch.int8)
1228+
)
1229+
dq = builder.call_operator(
1230+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1231+
args=(x_int8, 0.01, 0, -128, 127, torch.int8),
1232+
)
1233+
b = builder.placeholder("b", torch.randn(2, 4))
1234+
cat = builder.call_operator(exir_ops.edge.aten.cat.default, args=([dq, b], 0))
1235+
sliced = builder.call_operator(
1236+
exir_ops.edge.aten.slice_copy.Tensor, args=(cat, 0, 0, 2)
1237+
)
1238+
q1 = builder.call_operator(
1239+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1240+
args=(cat, 0.02, -5, -128, 127, torch.int8),
1241+
)
1242+
q2 = builder.call_operator(
1243+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1244+
args=(cat, 0.03, 10, -128, 127, torch.int8),
1245+
)
1246+
dq1 = builder.call_operator(
1247+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1248+
args=(q1, 0.02, -5, -128, 127, torch.int8),
1249+
)
1250+
dq2 = builder.call_operator(
1251+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
1252+
args=(q2, 0.03, 10, -128, 127, torch.int8),
1253+
)
1254+
builder.output([sliced, dq1, dq2])
1255+
gm = builder.get_graph_module()
1256+
1257+
result = SplitDequantizedCatPass().call(gm)
1258+
1259+
self.assertTrue(result.modified)
1260+
converted = result.graph_module
1261+
# Original cat + two separate duplicates = 3 cats total
1262+
self.assertEqual(count_node(converted, exir_ops.edge.aten.cat.default), 3)
1263+
1264+
# Each quant node should have a different cat input.
1265+
quant_nodes = converted.graph.find_nodes(
1266+
op="call_function",
1267+
target=exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
1268+
)
1269+
quant_cat_inputs = {node.args[0] for node in quant_nodes}
1270+
self.assertEqual(len(quant_cat_inputs), 2)

0 commit comments

Comments
 (0)