已合并
inductor/fx_passes: dynamic graph optimization e2e #44486
dezheng889创建于 8月13日
inductor/fx_passes: dynamic graph optimization e2e #44486
已合并
共 15 个文件变更+3176-221
| @@ -0,0 +1,337 @@ | |||
| 1 | +import operator | ||
| 2 | +import unittest | ||
| 3 | + | ||
| 4 | +import torch | ||
| 5 | +from torch.export import Dim, export | ||
| 6 | +from torch.fx.experimental.proxy_tensor import make_fx | ||
| 7 | +from torch.testing._internal.common_utils import ( | ||
| 8 | + instantiate_parametrized_tests, | ||
| 9 | + parametrize, | ||
| 10 | + run_tests, | ||
| 11 | +) | ||
| 12 | +from testutils import TestUtils | ||
| 13 | +import torch_npu | ||
| 14 | +import torch_npu._inductor | ||
| 15 | +from torch_npu._inductor import config as npu_config | ||
| 16 | +from torch_npu._inductor.fx_passes.ascend_custom_passes.ascend_graph_pass import ( | ||
| 17 | + _gmm_plan_batch_size, | ||
| 18 | + grouped_matmul_fusion_pass, | ||
| 19 | +) | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +_CAT = torch.ops.aten.cat.default | ||
| 23 | +_ADDMM = torch.ops.aten.addmm.default | ||
| 24 | +_MM = torch.ops.aten.mm.default | ||
| 25 | +_RELU = torch.ops.aten.relu.default | ||
| 26 | +_GMM = torch.ops.npu.npu_grouped_matmul.default | ||
| 27 | + | ||
| 28 | +_ROWS = 200 | ||
| 29 | +_WIDTH = 64 | ||
| 30 | +# Tower widths from a real model: three orders of magnitude, most far below a cube's capacity. | ||
| 31 | +_TOWER_K = (16, 32, 48, 64, 96, 128, 160, 192, 256, 320, 384, 512, 640, 768, 1024) | ||
| 32 | + | ||
| 33 | +_ENABLED = npu_config.enable_grouped_matmul_fusion | ||
| 34 | +_NEEDS_FLAG = "requires TORCHINDUCTOR_ENABLE_GROUPED_MATMUL_FUSION=1 (the pass registers on the switch)" | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def _count(graph, target): | ||
| 38 | + return len([n for n in graph.nodes if n.op == "call_function" and n.target == target]) | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +def _grouped_nodes(graph): | ||
| 42 | + return [n for n in graph.nodes if n.op == "call_function" and n.target == _GMM] | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def _cat_node(graph): | ||
| 46 | + return [n for n in graph.nodes if n.op == "call_function" and n.target == _CAT][-1] | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def _batch_sizes(graph): | ||
| 50 | + return [len(n.args[0]) for n in _grouped_nodes(graph)] | ||
| 51 | + | ||
| 52 | + | ||
| 53 | +def _member_order(graph): | ||
| 54 | + """Map each cat input back to (batch index, group index) to confirm concat order is intact. | ||
| 55 | + | ||
| 56 | + After the rewrite every cat input is a getitem, so node counts cannot catch a permutation, | ||
| 57 | + which would silently interleave the features of different towers. | ||
| 58 | + """ | ||
| 59 | + grouped = _grouped_nodes(graph) | ||
| 60 | + order = [] | ||
| 61 | + for inp in _cat_node(graph).args[0]: | ||
| 62 | + if not (isinstance(inp, torch.fx.Node) and inp.target is operator.getitem): | ||
| 63 | + order.append(None) | ||
| 64 | + continue | ||
| 65 | + order.append((grouped.index(inp.args[0]), inp.args[1])) | ||
| 66 | + return order | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +class _Captured: | ||
| 70 | + """A captured graph plus a callable that runs it.""" | ||
| 71 | + | ||
| 72 | + def __init__(self, gm, call): | ||
| 73 | + self.gm = gm | ||
| 74 | + self.graph = gm.graph | ||
| 75 | + self._call = call | ||
| 76 | + | ||
| 77 | + def __call__(self, *args): | ||
| 78 | + return self._call(*args) | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +class _FnModule(torch.nn.Module): | ||
| 82 | + """torch.export only accepts nn.Module, and the cases under test are functions. | ||
| 83 | + | ||
| 84 | + Inputs arrive as one tuple so dynamic_shapes stays a single positional spec no | ||
| 85 | + matter how many tensors a case passes; a tower of n projections passes 2n or 3n | ||
| 86 | + of them. Export flattens it back to one placeholder per tensor. | ||
| 87 | + """ | ||
| 88 | + | ||
| 89 | + def __init__(self, fn): | ||
| 90 | + super().__init__() | ||
| 91 | + self._fn = fn | ||
| 92 | + | ||
| 93 | + def forward(self, tensors): | ||
| 94 | + return self._fn(*tensors) | ||
| 95 | + | ||
| 96 | + | ||
| 97 | +def _capture(fn, *tensors, dynamic=False): | ||
| 98 | + """Capture the graph the pass actually operates on. | ||
| 99 | + | ||
| 100 | + Static mode uses ``tracing_mode="fake"`` so all placeholders share one FakeTensorMode, as in | ||
| 101 | + production. Dynamic mode uses ``torch.export`` with only dim 0 dynamic, because batch is the | ||
| 102 | + only symbolic shape in production and each branch's K is a compile-time constant. | ||
| 103 | + """ | ||
| 104 | + if not dynamic: | ||
| 105 | + gm = make_fx(fn, tracing_mode="fake")(*tensors) | ||
| 106 | + return _Captured(gm, gm) | ||
| 107 | + | ||
| 108 | + batch = Dim("batch", min=2) | ||
| 109 | + dynamic_shapes = tuple( | ||
| 110 | + {0: batch} if t.dim() == 2 and t.shape[0] == _ROWS else None for t in tensors | ||
| 111 | + ) | ||
| 112 | + program = export(_FnModule(fn), (tuple(tensors),), dynamic_shapes=(dynamic_shapes,)) | ||
| 113 | + return _Captured(program.graph_module, lambda *a: program.module()(tuple(a))) | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +class TestGroupedMatmulPass(TestUtils): | ||
| 117 | + def _tower_inputs(self, n, rows=_ROWS, width=_WIDTH, dtype="float16", bias=True): | ||
| 118 | + """Build n projections with distinct K but equal output width, plus the final cat inputs.""" | ||
| 119 | + torch_dtype = eval("torch." + dtype) | ||
| 120 | + device = torch.device("npu") | ||
| 121 | + ks = [_TOWER_K[i % len(_TOWER_K)] for i in range(n)] | ||
| 122 | + xs = [torch.randn((rows, k), dtype=torch_dtype, device=device) for k in ks] | ||
| 123 | + ws = [torch.randn((k, width), dtype=torch_dtype, device=device) for k in ks] | ||
| 124 | + if not bias: | ||
| 125 | + return xs + ws | ||
| 126 | + bs = [torch.randn((width,), dtype=torch_dtype, device=device) for _ in ks] | ||
| 127 | + return xs + ws + bs | ||
| 128 | + | ||
| 129 | + def _tower_fn(self, n, bias=True): | ||
| 130 | + def fn(*tensors): | ||
| 131 | + xs, ws = tensors[:n], tensors[n:2 * n] | ||
| 132 | + if bias: | ||
| 133 | + bs = tensors[2 * n:3 * n] | ||
| 134 | + parts = [_ADDMM(b, x, w) for b, x, w in zip(bs, xs, ws)] | ||
| 135 | + else: | ||
| 136 | + parts = [_MM(x, w) for x, w in zip(xs, ws)] | ||
| 137 | + return _CAT(parts, 1) | ||
| 138 | + | ||
| 139 | + return fn | ||
| 140 | + | ||
| 141 | + def _run_pass(self, fn, *tensors, dynamic=False, **limits): | ||
| 142 | + captured = _capture(fn, *tensors, dynamic=dynamic) | ||
| 143 | + grouped_matmul_fusion_pass(captured.graph, **limits) | ||
| 144 | + captured.gm.recompile() | ||
| 145 | + return captured | ||
| 146 | + | ||
| 147 | + # ------------------------------------------------------------------ | ||
| 148 | + # Batching rule. The cliff sits between 32 and 40 groups: minimize batches, then split evenly. | ||
| 149 | + # ------------------------------------------------------------------ | ||
| 150 | + | ||
| 151 | + "total,expected", | ||
| 152 | + [ | ||
| 153 | + (8, 8), # fits in one batch | ||
| 154 | + (32, 32), # exactly at the cap | ||
| 155 | + (33, 17), # just over the cap: two even batches, not 32 + 1 | ||
| 156 | + (64, 32), | ||
| 157 | + (80, 27), # measured optimum: 3 batches of 27/27/26, 14% faster than 32/32/16 | ||
| 158 | + (100, 25), | ||
| 159 | + ], | ||
| 160 | + ) | ||
| 161 | + def test_batch_size_is_balanced(self, total, expected): | ||
| 162 | + self.assertEqual(_gmm_plan_batch_size(total, 32), expected) | ||
| 163 | + | ||
| 164 | + def test_batch_size_never_exceeds_cap(self): | ||
| 165 | + for cap in (8, 16, 32): | ||
| 166 | + for total in range(2, 200): | ||
| 167 | + self.assertLessEqual(_gmm_plan_batch_size(total, cap), cap, | ||
| 168 | + msg=f"cap={cap} total={total}") | ||
| 169 | + | ||
| 170 | + # ------------------------------------------------------------------ | ||
| 171 | + # Graph rewriting | ||
| 172 | + # ------------------------------------------------------------------ | ||
| 173 | + def test_folds_tower_into_balanced_batches(self): | ||
| 174 | + n = 80 | ||
| 175 | + captured = self._run_pass(self._tower_fn(n), *self._tower_inputs(n), | ||
| 176 | + max_groups_per_call=32) | ||
| 177 | + self.assertEqual(_batch_sizes(captured.graph), [27, 27, 26]) | ||
| 178 | + self.assertEqual(_count(captured.graph, _ADDMM), 0) | ||
| 179 | + # Dispatch count is the whole point of this rewrite: 81 (80 matmul + 1 cat) down to 4. | ||
| 180 | + self.assertEqual(_count(captured.graph, _GMM) + _count(captured.graph, _CAT), 4) | ||
| 181 | + | ||
| 182 | + def test_preserves_concat_order(self): | ||
| 183 | + n = 80 | ||
| 184 | + captured = self._run_pass(self._tower_fn(n), *self._tower_inputs(n), | ||
| 185 | + max_groups_per_call=32) | ||
| 186 | + expected = ([(0, i) for i in range(27)] | ||
| 187 | + + [(1, i) for i in range(27)] | ||
| 188 | + + [(2, i) for i in range(26)]) | ||
| 189 | + self.assertEqual(_member_order(captured.graph), expected) | ||
| 190 | + | ||
| 191 | + def test_passes_group_semantics(self): | ||
| 192 | + """split_item=0 emits one output per group; group_type=-1 gives each group its own K.""" | ||
| 193 | + n = 16 | ||
| 194 | + captured = self._run_pass(self._tower_fn(n), *self._tower_inputs(n)) | ||
| 195 | + for node in _grouped_nodes(captured.graph): | ||
| 196 | + self.assertEqual(node.kwargs["split_item"], 0) | ||
| 197 | + self.assertEqual(node.kwargs["group_type"], -1) | ||
| 198 | + self.assertEqual(len(node.kwargs["bias"]), len(node.args[0])) | ||
| 199 | + self.assertEqual(len(node.args[0]), len(node.args[1])) | ||
| 200 | + | ||
| 201 | + def test_folds_without_bias(self): | ||
| 202 | + n = 16 | ||
| 203 | + captured = self._run_pass(self._tower_fn(n, bias=False), | ||
| 204 | + *self._tower_inputs(n, bias=False)) | ||
| 205 | + self.assertEqual(_count(captured.graph, _MM), 0) | ||
| 206 | + for node in _grouped_nodes(captured.graph): | ||
| 207 | + self.assertNotIn("bias", node.kwargs) | ||
| 208 | + | ||
| 209 | + def test_folds_under_dynamic_batch(self): | ||
| 210 | + """Row count is symbolic in production graphs; a symbolic dim must not block matching.""" | ||
| 211 | + n = 16 | ||
| 212 | + captured = self._run_pass(self._tower_fn(n), *self._tower_inputs(n), | ||
| 213 | + dynamic=True) | ||
| 214 | + self.assertEqual(_count(captured.graph, _GMM), 1) | ||
| 215 | + self.assertEqual(_count(captured.graph, _ADDMM), 0) | ||
| 216 | + | ||
| 217 | + def test_splits_biased_and_plain_into_separate_calls(self): | ||
| 218 | + """Biased and plain matmuls cannot share a batch: bias is a whole-call argument.""" | ||
| 219 | + n = 20 | ||
| 220 | + tensors = self._tower_inputs(n) | ||
| 221 | + | ||
| 222 | + def fn(*t): | ||
| 223 | + xs, ws, bs = t[:n], t[n:2 * n], t[2 * n:] | ||
| 224 | + parts = [_ADDMM(b, x, w) for b, x, w in zip(bs[:10], xs[:10], ws[:10])] | ||
| 225 | + parts += [_MM(x, w) for x, w in zip(xs[10:], ws[10:])] | ||
| 226 | + return _CAT(parts, 1) | ||
| 227 | + | ||
| 228 | + captured = self._run_pass(fn, *tensors, min_groups=8) | ||
| 229 | + self.assertEqual(_count(captured.graph, _GMM), 2) | ||
| 230 | + with_bias = [n_ for n_ in _grouped_nodes(captured.graph) if "bias" in n_.kwargs] | ||
| 231 | + self.assertEqual(len(with_bias), 1) | ||
| 232 | + | ||
| 233 | + # ------------------------------------------------------------------ | ||
| 234 | + # Rejection conditions | ||
| 235 | + # ------------------------------------------------------------------ | ||
| 236 | + def test_skips_few_branches(self): | ||
| 237 | + """Few wide GEMMs like QKV already fill the cube; grouping them costs 12% device time.""" | ||
| 238 | + n = 3 | ||
| 239 | + captured = self._run_pass(self._tower_fn(n), *self._tower_inputs(n), | ||
| 240 | + min_groups=8) | ||
| 241 | + self.assertEqual(_count(captured.graph, _GMM), 0) | ||
| 242 | + self.assertEqual(_count(captured.graph, _ADDMM), n) | ||
| 243 | + | ||
| 244 | + def test_skips_wide_rows_when_static(self): | ||
| 245 | + n = 16 | ||
| 246 | + captured = self._run_pass(self._tower_fn(n), | ||
| 247 | + *self._tower_inputs(n, rows=_ROWS), | ||
| 248 | + max_rows=_ROWS - 1) | ||
| 249 | + self.assertEqual(_count(captured.graph, _GMM), 0) | ||
| 250 | + | ||
| 251 | + def test_skips_row_concat(self): | ||
| 252 | + n = 16 | ||
| 253 | + tensors = self._tower_inputs(n, width=_WIDTH) | ||
| 254 | + | ||
| 255 | + def fn(*t): | ||
| 256 | + xs, ws, bs = t[:n], t[n:2 * n], t[2 * n:] | ||
| 257 | + return _CAT([_ADDMM(b, x, w) for b, x, w in zip(bs, xs, ws)], 0) | ||
| 258 | + | ||
| 259 | + captured = self._run_pass(fn, *tensors) | ||
| 260 | + self.assertEqual(_count(captured.graph, _GMM), 0) | ||
| 261 | + | ||
| 262 | + def test_skips_mismatched_width(self): | ||
| 263 | + """Differing output widths form separate buckets, neither meeting the minimum group.""" | ||
| 264 | + n = 16 | ||
| 265 | + device = torch.device("npu") | ||
| 266 | + xs = [torch.randn((_ROWS, 64), dtype=torch.float16, device=device) | ||
| 267 | + for _ in range(n)] | ||
| 268 | + ws = [torch.randn((64, _WIDTH if i < n // 2 else _WIDTH * 2), | ||
| 269 | + dtype=torch.float16, device=device) for i in range(n)] | ||
| 270 | + bs = [torch.randn((_WIDTH if i < n // 2 else _WIDTH * 2,), | ||
| 271 | + dtype=torch.float16, device=device) for i in range(n)] | ||
| 272 | + | ||
| 273 | + def fn(*t): | ||
| 274 | + a, b, c = t[:n], t[n:2 * n], t[2 * n:] | ||
| 275 | + return _CAT([_ADDMM(z, x, w) for z, x, w in zip(c, a, b)], 1) | ||
| 276 | + | ||
| 277 | + captured = self._run_pass(fn, *xs, *ws, *bs, min_groups=12) | ||
| 278 | + self.assertEqual(_count(captured.graph, _GMM), 0) | ||
| 279 | + | ||
| 280 | + def test_skips_scaled_addmm(self): | ||
| 281 | + """addmm with beta/alpha is not plain x @ w + b; the grouped operator cannot express it.""" | ||
| 282 | + n = 16 | ||
| 283 | + tensors = self._tower_inputs(n) | ||
| 284 | + | ||
| 285 | + def fn(*t): | ||
| 286 | + xs, ws, bs = t[:n], t[n:2 * n], t[2 * n:] | ||
| 287 | + parts = [_ADDMM(b, x, w, beta=2.0) for b, x, w in zip(bs, xs, ws)] | ||
| 288 | + return _CAT(parts, 1) | ||
| 289 | + | ||
| 290 | + captured = self._run_pass(fn, *tensors) | ||
| 291 | + self.assertEqual(_count(captured.graph, _GMM), 0) | ||
| 292 | + self.assertEqual(_count(captured.graph, _ADDMM), n) | ||
| 293 | + | ||
| 294 | + def test_skips_matmul_with_other_users(self): | ||
| 295 | + """Extra users force the grouped call before the earliest user, so the gain is uncertain.""" | ||
| 296 | + n = 16 | ||
| 297 | + tensors = self._tower_inputs(n) | ||
| 298 | + | ||
| 299 | + def fn(*t): | ||
| 300 | + xs, ws, bs = t[:n], t[n:2 * n], t[2 * n:] | ||
| 301 | + parts = [_ADDMM(b, x, w) for b, x, w in zip(bs, xs, ws)] | ||
| 302 | + return _CAT(parts, 1), _RELU(parts[0]) | ||
| 303 | + | ||
| 304 | + captured = self._run_pass(fn, *tensors, min_groups=8) | ||
| 305 | + # Only the first branch has an extra user; the remaining 15 still meet the minimum. | ||
| 306 | + self.assertEqual(_batch_sizes(captured.graph), [15]) | ||
| 307 | + self.assertEqual(_count(captured.graph, _ADDMM), 1) | ||
| 308 | + | ||
| 309 | + # ------------------------------------------------------------------ | ||
| 310 | + # Numerics. Not bitwise-identical: the operator accumulates in a different order than separate | ||
| 311 | + # matmuls; measured relative RMS error in fp16 is 6.6e-06, matmul's own noise level. | ||
| 312 | + # ------------------------------------------------------------------ | ||
| 313 | + | ||
| 314 | + | ||
| 315 | + def test_matches_separate_matmuls(self, n): | ||
| 316 | + tensors = self._tower_inputs(n) | ||
| 317 | + fn = self._tower_fn(n) | ||
| 318 | + expected = fn(*tensors) | ||
| 319 | + captured = self._run_pass(fn, *tensors, | ||
| 320 | + max_groups_per_call=32) | ||
| 321 | + self.assertGreater(_count(captured.graph, _GMM), 0, "pass did not fire, so this case proves nothing") | ||
| 322 | + actual = captured(*tensors) | ||
| 323 | + self.assertEqual(expected.shape, actual.shape) | ||
| 324 | + | ||
| 325 | + # Element-wise comparison is meaningless here: accumulating 1024 fp16 products puts one ULP | ||
| 326 | + # at 3e-2, so any element-wise threshold is either too loose to catch bugs or bound to | ||
| 327 | + # flake. Relative RMS error is stable: 6.6e-06 measured at harder tower widths (K to 5036). | ||
| 328 | + diff = (actual.float() - expected.float()) | ||
| 329 | + rms = diff.pow(2).mean().sqrt() / expected.float().pow(2).mean().sqrt() | ||
| 330 | + self.assertLess(rms.item(), 1e-4, f"relative RMS error {rms.item():.3e} is too large") | ||
| 331 | + | ||
| 332 | + | ||
| 333 | +instantiate_parametrized_tests(TestGroupedMatmulPass) | ||
| 334 | + | ||
| 335 | + | ||
| 336 | +if __name__ == "__main__": | ||
| 337 | + run_tests() | ||
| @@ -0,0 +1,1014 @@ | |||
| 1 | +import contextlib | ||
| 2 | +import types | ||
| 3 | +import unittest | ||
| 4 | + | ||
| 5 | +import torch | ||
| 6 | +from torch.export import Dim, export | ||
| 7 | +from torch.fx.experimental.proxy_tensor import make_fx | ||
| 8 | +from torch.testing._internal.common_utils import ( | ||
| 9 | + instantiate_parametrized_tests, | ||
| 10 | + parametrize, | ||
| 11 | + run_tests, | ||
| 12 | +) | ||
| 13 | +from testutils import TestUtils | ||
| 14 | +import torch_npu | ||
| 15 | +import torch_npu._inductor | ||
| 16 | +from torch_npu._inductor import config as npu_config | ||
| 17 | +from torch_npu._inductor.fx_passes.ascend_custom_passes.ascend_graph_pass import ( | ||
| 18 | + MULTI_SLICE_CONCAT_TARGET, | ||
| 19 | + multi_slice_concat_pass, | ||
| 20 | +) | ||
| 21 | +from torch_npu._inductor.kernel.multi_slice_concat import _dedup_inputs | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +_CAT = torch.ops.aten.cat.default | ||
| 25 | +_SLICE = torch.ops.aten.slice.Tensor | ||
| 26 | +_WHERE = torch.ops.aten.where.self | ||
| 27 | +_FULL = torch.ops.aten.full.default | ||
| 28 | +_MSC = torch.ops.npu_ext.multi_slice_concat | ||
| 29 | + | ||
| 30 | +# operator arg positions, so node.args is not indexed by bare numbers | ||
| 31 | +_A_SRCS, _A_MASKS, _A_SRC_IDX, _A_OFFSETS, _A_WIDTHS, _A_MASK_IDX = range(6) | ||
| 32 | + | ||
| 33 | +# wide feature table and offsets from real model output code, all compile-time constants. | ||
| 34 | +_WIDE_COLS = 69876 | ||
| 35 | +# L24837: 15 segments, width=128. | ||
| 36 | +_W128_OFFSETS = (55834, 56452, 45421, 44203, 44744, 45962, 60741, 61328, | ||
| 37 | + 62267, 65602, 66495, 66956, 67445, 68021, 69060) | ||
| 38 | +# L24895: 12 segments, width=16. | ||
| 39 | +_W16_OFFSETS = (55818, 56436, 45405, 44187, 44728, 45946, 61312, 62251, | ||
| 40 | + 69044, 68199, 68221, 69822) | ||
| 41 | +# L25021: 45 mixed-width segments, the longest column concat in the model. | ||
| 42 | +_MIXED_OFFSETS = (55818, 56436, 45405, 44187, 44728, 45946, 61312, 62251, 69044, | ||
| 43 | + 45421, 44203, 44744, 45962, 60741, 61328, 62267, 65602, 66495, | ||
| 44 | + 66956, 67445, 69060, 45549, 44331, 44872, 46090, 61456, 62395, | ||
| 45 | + 69188, 68199, 68221, 69822, 55580, 59302, 1693, 1749, 1807, | ||
| 46 | + 2033, 3276, 3350, 2463, 58205, 58980, 62964, 2159, 64802) | ||
| 47 | +_MIXED_WIDTHS = (16, 16, 16, 16, 16, 16, 16, 16, 16, 128, 128, 128, 128, 128, 128, | ||
| 48 | + 128, 128, 128, 128, 128, 128, 32, 32, 32, 32, 32, 32, 32, 16, 16, | ||
| 49 | + 16, 128, 128, 32, 32, 32, 32, 32, 32, 32, 16, 16, 16, 16, 16) | ||
| 50 | +# L27947: 28 segments, width=8, the narrowest ones in the model. | ||
| 51 | +_W8_OFFSETS = (48025, 47595, 46868, 47890, 48779, 49346, 47769, 49082, 49180, | ||
| 52 | + 48337, 48467, 48597, 48402, 48532, 48714, 49240, 48070, 48204, | ||
| 53 | + 48119, 11076, 14257, 18643, 11922, 15103, 19489, 12752, 15933, 20319) | ||
| 54 | +# cat_124: masked and bare slices strictly alternating; nothing merged before masks. | ||
| 55 | +_ALT_OFFSETS = (35098, 39296, 35470, 39913, 34648, 38647, 35842, 40530) | ||
| 56 | +_ALT_WIDTHS = (80, 80, 80, 80, 112, 112, 128, 128) | ||
| 57 | + | ||
| 58 | +_ENABLED = npu_config.enable_multi_slice_concat | ||
| 59 | +_NEEDS_FLAG = "needs TORCHINDUCTOR_ENABLE_MULTI_SLICE_CONCAT=1 (pass is registered by the flag)" | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def _count(graph, target): | ||
| 63 | + return len([n for n in graph.nodes if n.op == "call_function" and n.target == target]) | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def _op_nodes(graph): | ||
| 67 | + return [n for n in graph.nodes | ||
| 68 | + if n.op == "call_function" and n.target == MULTI_SLICE_CONCAT_TARGET] | ||
| 69 | + | ||
| 70 | + | ||
| 71 | + | ||
| 72 | +def _pass_spy(): | ||
| 73 | + """Count the op nodes the pass produces during compilation. | ||
| 74 | + | ||
| 75 | + When the pass does not fire the result falls back to aten.cat and is still | ||
| 76 | + bitwise correct, so a numeric-only end-to-end check passes vacuously. The | ||
| 77 | + pass is registered as a function object, so swap in a counting wrapper. | ||
| 78 | + """ | ||
| 79 | + from torch_npu._inductor.fx_passes.ascend_custom_passes.register_custom_pass import ( | ||
| 80 | + ASCEND_CUSTOME_PASS_REGISTER, | ||
| 81 | + ) | ||
| 82 | + | ||
| 83 | + stats = {"nodes": 0} | ||
| 84 | + original = [] | ||
| 85 | + | ||
| 86 | + def spy(graph): | ||
| 87 | + multi_slice_concat_pass(graph) | ||
| 88 | + stats["nodes"] += len(_op_nodes(graph)) | ||
| 89 | + | ||
| 90 | + spy.__name__ = multi_slice_concat_pass.__name__ | ||
| 91 | + for pass_type, levels in ASCEND_CUSTOME_PASS_REGISTER.items(): | ||
| 92 | + for level, fns in levels.items(): | ||
| 93 | + if any(getattr(f, "__name__", "") == spy.__name__ for f in fns): | ||
| 94 | + original.append((pass_type, level, list(fns))) | ||
| 95 | + levels[level] = [ | ||
| 96 | + spy if getattr(f, "__name__", "") == spy.__name__ else f | ||
| 97 | + for f in fns | ||
| 98 | + ] | ||
| 99 | + try: | ||
| 100 | + yield stats | ||
| 101 | + finally: | ||
| 102 | + for pass_type, level, fns in original: | ||
| 103 | + ASCEND_CUSTOME_PASS_REGISTER[pass_type][level] = fns | ||
| 104 | + | ||
| 105 | + | ||
| 106 | +class _Captured: | ||
| 107 | + def __init__(self, gm, call): | ||
| 108 | + self.gm = gm | ||
| 109 | + self.graph = gm.graph | ||
| 110 | + self._call = call | ||
| 111 | + | ||
| 112 | + def __call__(self, *args): | ||
| 113 | + return self._call(*args) | ||
| 114 | + | ||
| 115 | + | ||
| 116 | +class _FnModule(torch.nn.Module): | ||
| 117 | + """torch.export only accepts nn.Module, and the cases under test are functions. | ||
| 118 | + | ||
| 119 | + Inputs arrive as one tuple so dynamic_shapes stays a single positional spec no | ||
| 120 | + matter how many tensors a case passes; export flattens it back to one | ||
| 121 | + placeholder per tensor, which is what the pass sees. | ||
| 122 | + """ | ||
| 123 | + | ||
| 124 | + def __init__(self, fn): | ||
| 125 | + super().__init__() | ||
| 126 | + self._fn = fn | ||
| 127 | + | ||
| 128 | + def forward(self, tensors): | ||
| 129 | + return self._fn(*tensors) | ||
| 130 | + | ||
| 131 | + | ||
| 132 | +def _capture(fn, *tensors, dynamic=False): | ||
| 133 | + """Capture the graph the pass actually sees. | ||
| 134 | + | ||
| 135 | + Static uses ``tracing_mode="fake"``, not ``"real"``: real builds one | ||
| 136 | + FakeTensorMode per input, so inferring output meta for multi-source segments | ||
| 137 | + hits "Mixing fake modes" and silently skips the rewrite. | ||
| 138 | + | ||
| 139 | + Dynamic uses ``torch.export`` with only dim 0 dynamic, like production: | ||
| 140 | + symbolic batch, static last dim. ``tracing_mode="symbolic"`` would symbolize | ||
| 141 | + the last dim too, leaving the pass without constant offsets so it silently | ||
| 142 | + stops matching. | ||
| 143 | + """ | ||
| 144 | + if not dynamic: | ||
| 145 | + gm = make_fx(fn, tracing_mode="fake")(*tensors) | ||
| 146 | + return _Captured(gm, gm) | ||
| 147 | + | ||
| 148 | + batch = Dim("batch", min=2) | ||
| 149 | + program = export(_FnModule(fn), (tuple(tensors),), | ||
| 150 | + dynamic_shapes=(tuple({0: batch} for _ in tensors),)) | ||
| 151 | + return _Captured(program.graph_module, lambda *a: program.module()(tuple(a))) | ||
| 152 | + | ||
| 153 | + | ||
| 154 | +def _col_concat(wide, offsets, widths): | ||
| 155 | + """Reproduce the [aten.slice] -> cat(dim=-1) shape from the output code.""" | ||
| 156 | + parts = [_SLICE(wide, -1, off, off + w) for off, w in zip(offsets, widths)] | ||
| 157 | + return _CAT(parts, -1) | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def _col_concat_ref(wide, offsets, widths): | ||
| 161 | + return torch.cat([wide[..., off:off + w] for off, w in zip(offsets, widths)], dim=-1) | ||
| 162 | + | ||
| 163 | + | ||
| 164 | +def _masked_slice(wide, mask, off, width): | ||
| 165 | + """Reproduce the where(row mask, zeros, column slice) shape from output code.""" | ||
| 166 | + part = _SLICE(wide, -1, off, off + width) | ||
| 167 | + zero = _FULL([wide.shape[0], width], 0, dtype=wide.dtype, device=wide.device) | ||
| 168 | + return _WHERE(mask, zero, part) | ||
| 169 | + | ||
| 170 | + | ||
| 171 | +def _masked_slice_ref(wide, mask, off, width): | ||
| 172 | + return torch.where(mask, torch.zeros_like(wide[..., off:off + width]), | ||
| 173 | + wide[..., off:off + width]) | ||
| 174 | + | ||
| 175 | + | ||
| 176 | +class _StubInput: | ||
| 177 | + """_dedup_inputs only reads buffer name and layout, so no real IR nodes needed.""" | ||
| 178 | + | ||
| 179 | + def __init__(self, name, size=(2, 1), stride=(1, 1), offset=0): | ||
| 180 | + self._name = name | ||
| 181 | + self._layout = types.SimpleNamespace( | ||
| 182 | + size=list(size), stride=list(stride), offset=offset | ||
| 183 | + ) | ||
| 184 | + | ||
| 185 | + def get_name(self): | ||
| 186 | + return self._name | ||
| 187 | + | ||
| 188 | + def get_layout(self): | ||
| 189 | + return self._layout | ||
| 190 | + | ||
| 191 | + | ||
| 192 | +class TestMultiSliceConcatPass(TestUtils): | ||
| 193 | + # pure data movement plus mask select, so output must be bitwise-identical to cat. | ||
| 194 | + def _assert_bitwise_equal(self, expected, actual, note=""): | ||
| 195 | + self.assertEqual(expected, actual, atol=0, rtol=0, | ||
| 196 | + msg=f"{note}: a pure-copy rewrite must be bitwise identical") | ||
| 197 | + | ||
| 198 | + def _wide(self, rows, dtype='float16', cols=_WIDE_COLS): | ||
| 199 | + return torch.randn((rows, cols), dtype=eval('torch.' + dtype), | ||
| 200 | + device=torch.device("npu")) | ||
| 201 | + | ||
| 202 | + def _mask(self, rows, cols=1): | ||
| 203 | + """Row-wise bool mask [rows, 1], matching logical_not in the model.""" | ||
| 204 | + return torch.randint(0, 2, (rows, cols), device=torch.device("npu"), | ||
| 205 | + dtype=torch.bool) | ||
| 206 | + | ||
| 207 | + def _run_pass(self, fn, *tensors, dynamic=False, **limits): | ||
| 208 | + captured = _capture(fn, *tensors, dynamic=dynamic) | ||
| 209 | + multi_slice_concat_pass(captured.graph, **limits) | ||
| 210 | + captured.gm.recompile() | ||
| 211 | + return captured | ||
| 212 | + | ||
| 213 | + # ------------------------------------------------------------------ | ||
| 214 | + # operator contract: eager runs the CompositeExplicitAutograd reference impl | ||
| 215 | + # (per-segment copy + cat), not the fused kernel, which only exists after | ||
| 216 | + # lowering. These pin semantics and arg validation; perf in bench_multi_slice_concat.py. | ||
| 217 | + # ------------------------------------------------------------------ | ||
| 218 | + | ||
| 219 | + | ||
| 220 | + def test_op_matches_cat_of_slices(self, rows, dtype): | ||
| 221 | + wide = self._wide(rows, dtype) | ||
| 222 | + widths = (128,) * len(_W128_OFFSETS) | ||
| 223 | + n = len(widths) | ||
| 224 | + out = _MSC([wide], [], [0] * n, list(_W128_OFFSETS), list(widths), [-1] * n) | ||
| 225 | + self._assert_bitwise_equal(_col_concat_ref(wide, _W128_OFFSETS, widths), out, | ||
| 226 | + "op reference impl") | ||
| 227 | + self.assertEqual(out.shape, (rows, sum(widths))) | ||
| 228 | + | ||
| 229 | + | ||
| 230 | + | ||
| 231 | + def test_op_handles_mixed_widths(self, rows, dtype): | ||
| 232 | + wide = self._wide(rows, dtype) | ||
| 233 | + n = len(_MIXED_WIDTHS) | ||
| 234 | + out = _MSC([wide], [], [0] * n, list(_MIXED_OFFSETS), list(_MIXED_WIDTHS), | ||
| 235 | + [-1] * n) | ||
| 236 | + self._assert_bitwise_equal( | ||
| 237 | + _col_concat_ref(wide, _MIXED_OFFSETS, _MIXED_WIDTHS), out, "45 segments mixed widths" | ||
| 238 | + ) | ||
| 239 | + | ||
| 240 | + | ||
| 241 | + | ||
| 242 | + def test_op_handles_non_pow2_width(self, rows, dtype): | ||
| 243 | + """Model widths are all powers of two, but the template padding branch must work.""" | ||
| 244 | + wide = self._wide(rows, dtype) | ||
| 245 | + offsets, widths = (100, 500, 900), (24, 48, 12) | ||
| 246 | + out = _MSC([wide], [], [0] * 3, list(offsets), list(widths), [-1] * 3) | ||
| 247 | + self._assert_bitwise_equal(_col_concat_ref(wide, offsets, widths), out, | ||
| 248 | + "non power-of-2 widths") | ||
| 249 | + | ||
| 250 | + | ||
| 251 | + | ||
| 252 | + def test_op_masked_segment(self, rows, dtype): | ||
| 253 | + """A masked segment is equivalent to where(row mask, 0, slice).""" | ||
| 254 | + wide = self._wide(rows, dtype) | ||
| 255 | + mask = self._mask(rows) | ||
| 256 | + offsets, widths = (100, 500, 900), (16, 32, 64) | ||
| 257 | + out = _MSC([wide], [mask], [0] * 3, list(offsets), list(widths), [0, -1, 0]) | ||
| 258 | + | ||
| 259 | + expected = torch.cat([ | ||
| 260 | + _masked_slice_ref(wide, mask, 100, 16), | ||
| 261 | + wide[..., 500:532], | ||
| 262 | + _masked_slice_ref(wide, mask, 900, 964 - 900), | ||
| 263 | + ], dim=-1) | ||
| 264 | + self._assert_bitwise_equal(expected, out, "masked and bare segments mixed") | ||
| 265 | + | ||
| 266 | + | ||
| 267 | + | ||
| 268 | + def test_op_multi_source(self, rows, dtype): | ||
| 269 | + """src_idx picks which source tensor each segment reads from.""" | ||
| 270 | + a = self._wide(rows, dtype, cols=1024) | ||
| 271 | + b = self._wide(rows, dtype, cols=512) | ||
| 272 | + out = _MSC([a, b], [], [0, 1, 0], [100, 64, 700], [32, 16, 48], [-1] * 3) | ||
| 273 | + | ||
| 274 | + expected = torch.cat([a[..., 100:132], b[..., 64:80], a[..., 700:748]], dim=-1) | ||
| 275 | + self._assert_bitwise_equal(expected, out, "two-source segment plan") | ||
| 276 | + | ||
| 277 | + | ||
| 278 | + | ||
| 279 | + def test_op_rejects_out_of_range_segment(self, rows, dtype): | ||
| 280 | + wide = self._wide(rows, dtype) | ||
| 281 | + with self.assertRaises(RuntimeError): | ||
| 282 | + _MSC([wide], [], [0], [_WIDE_COLS - 8], [16], [-1]) | ||
| 283 | + | ||
| 284 | + | ||
| 285 | + | ||
| 286 | + def test_op_rejects_bad_plan(self, rows, dtype): | ||
| 287 | + """Mismatched plan lengths or out-of-range indices must raise, not miscompute.""" | ||
| 288 | + wide = self._wide(rows, dtype) | ||
| 289 | + with self.assertRaises(RuntimeError): | ||
| 290 | + _MSC([wide], [], [0, 0], [100], [16], [-1]) | ||
| 291 | + with self.assertRaises(RuntimeError): | ||
| 292 | + _MSC([wide], [], [3], [100], [16], [-1]) | ||
| 293 | + with self.assertRaises(RuntimeError): | ||
| 294 | + _MSC([wide], [], [0], [100], [16], [0]) | ||
| 295 | + | ||
| 296 | + | ||
| 297 | + | ||
| 298 | + def test_op_rejects_non_row_mask(self, rows, dtype): | ||
| 299 | + """A non-[rows, 1] mask broadcasts differently, so it must raise, not guess.""" | ||
| 300 | + wide = self._wide(rows, dtype) | ||
| 301 | + bad = torch.randint(0, 2, (rows, 16), device=torch.device("npu"), | ||
| 302 | + dtype=torch.bool) | ||
| 303 | + with self.assertRaises(RuntimeError): | ||
| 304 | + _MSC([wide], [bad], [0], [100], [16], [0]) | ||
| 305 | + | ||
| 306 | + # ------------------------------------------------------------------ | ||
| 307 | + # rewrite under static and dynamic shapes | ||
| 308 | + # ------------------------------------------------------------------ | ||
| 309 | + | ||
| 310 | + | ||
| 311 | + | ||
| 312 | + def test_whole_cat_collapses(self, rows, dtype, dynamic): | ||
| 313 | + wide = self._wide(rows, dtype) | ||
| 314 | + widths = (128,) * len(_W128_OFFSETS) | ||
| 315 | + | ||
| 316 | + def fn(x): | ||
| 317 | + return _col_concat(x, _W128_OFFSETS, widths) | ||
| 318 | + | ||
| 319 | + gm = self._run_pass(fn, wide, dynamic=dynamic) | ||
| 320 | + | ||
| 321 | + self.assertEqual(_count(gm.graph, _CAT), 0, "slices should be replaced by the op, cat gone") | ||
| 322 | + self.assertEqual(len(_op_nodes(gm.graph)), 1) | ||
| 323 | + self.assertEqual(_count(gm.graph, _SLICE), 0, "slices should be eliminated too") | ||
| 324 | + self._assert_bitwise_equal(_col_concat_ref(wide, _W128_OFFSETS, widths), | ||
| 325 | + gm(wide), f"15 segments width=128 (dynamic={dynamic})") | ||
| 326 | + | ||
| 327 | + | ||
| 328 | + | ||
| 329 | + def test_mixed_widths_from_real_model(self, rows, dtype): | ||
| 330 | + """L25021 prototype: 45 segments with widths mixed 16/32/128.""" | ||
| 331 | + wide = self._wide(rows, dtype) | ||
| 332 | + | ||
| 333 | + def fn(x): | ||
| 334 | + return _col_concat(x, _MIXED_OFFSETS, _MIXED_WIDTHS) | ||
| 335 | + | ||
| 336 | + gm = self._run_pass(fn, wide) | ||
| 337 | + ops = _op_nodes(gm.graph) | ||
| 338 | + | ||
| 339 | + self.assertEqual(_count(gm.graph, _CAT), 0) | ||
| 340 | + self.assertEqual(len(ops), 1, "45 segments of one base should collapse into one op") | ||
| 341 | + self.assertEqual(len(ops[0].args[_A_OFFSETS]), 45, "all segments must be preserved") | ||
| 342 | + self.assertEqual(list(ops[0].args[_A_OFFSETS]), list(_MIXED_OFFSETS), | ||
| 343 | + "offset order must not change") | ||
| 344 | + self._assert_bitwise_equal( | ||
| 345 | + _col_concat_ref(wide, _MIXED_OFFSETS, _MIXED_WIDTHS), gm(wide), "45 segments mixed widths" | ||
| 346 | + ) | ||
| 347 | + | ||
| 348 | + | ||
| 349 | + | ||
| 350 | + def test_narrow_segments(self, rows, dtype): | ||
| 351 | + """L27947 prototype: 28 segments of width=8, the narrowest case.""" | ||
| 352 | + wide = self._wide(rows, dtype) | ||
| 353 | + widths = (8,) * len(_W8_OFFSETS) | ||
| 354 | + | ||
| 355 | + def fn(x): | ||
| 356 | + return _col_concat(x, _W8_OFFSETS, widths) | ||
| 357 | + | ||
| 358 | + gm = self._run_pass(fn, wide) | ||
| 359 | + self.assertEqual(len(_op_nodes(gm.graph)), 1) | ||
| 360 | + self._assert_bitwise_equal(_col_concat_ref(wide, _W8_OFFSETS, widths), | ||
| 361 | + gm(wide), "28 segments width=8") | ||
| 362 | + | ||
| 363 | + | ||
| 364 | + | ||
| 365 | + def test_dynamic_batch_reruns_with_other_shape(self, rows, dtype): | ||
| 366 | + wide = self._wide(rows, dtype) | ||
| 367 | + widths = (16,) * len(_W16_OFFSETS) | ||
| 368 | + | ||
| 369 | + def fn(x): | ||
| 370 | + return _col_concat(x, _W16_OFFSETS, widths) | ||
| 371 | + | ||
| 372 | + gm = self._run_pass(fn, wide, dynamic=True) | ||
| 373 | + self.assertEqual(len(_op_nodes(gm.graph)), 1) | ||
| 374 | + | ||
| 375 | + # batch is never 1: dims 0/1 are hard-specialized, so symbols range over [2, inf). | ||
| 376 | + for other_rows in (2, rows * 3): | ||
| 377 | + other = self._wide(other_rows, dtype) | ||
| 378 | + self._assert_bitwise_equal(_col_concat_ref(other, _W16_OFFSETS, widths), | ||
| 379 | + gm(other), f"dynamic batch rows={other_rows}") | ||
| 380 | + | ||
| 381 | + | ||
| 382 | + | ||
| 383 | + def test_new_node_carries_fake_meta(self, rows, dtype): | ||
| 384 | + wide = self._wide(rows, dtype) | ||
| 385 | + widths = (16,) * len(_W16_OFFSETS) | ||
| 386 | + | ||
| 387 | + def fn(x): | ||
| 388 | + return _col_concat(x, _W16_OFFSETS, widths) | ||
| 389 | + | ||
| 390 | + node = _op_nodes(self._run_pass(fn, wide).graph)[0] | ||
| 391 | + | ||
| 392 | + self.assertIn('val', node.meta, "new node must carry meta['val'] or lowering has no shape") | ||
| 393 | + self.assertEqual(node.meta['val'].dtype, torch.float16) | ||
| 394 | + self.assertEqual(int(node.meta['val'].shape[-1]), sum(widths)) | ||
| 395 | + | ||
| 396 | + # ------------------------------------------------------------------ | ||
| 397 | + # masked segments: more common than bare slices in production, and what breaks up runs | ||
| 398 | + # ------------------------------------------------------------------ | ||
| 399 | + | ||
| 400 | + | ||
| 401 | + | ||
| 402 | + def test_masked_slices_collapse(self, rows, dtype, dynamic): | ||
| 403 | + wide = self._wide(rows, dtype) | ||
| 404 | + mask = self._mask(rows) | ||
| 405 | + offsets, widths = (100, 500, 900, 1300), (16, 32, 64, 16) | ||
| 406 | + | ||
| 407 | + def fn(x, m): | ||
| 408 | + return _CAT([_masked_slice(x, m, o, w) | ||
| 409 | + for o, w in zip(offsets, widths)], -1) | ||
| 410 | + | ||
| 411 | + gm = self._run_pass(fn, wide, mask, dynamic=dynamic) | ||
| 412 | + ops = _op_nodes(gm.graph) | ||
| 413 | + | ||
| 414 | + self.assertEqual(len(ops), 1, "the whole masked slice run should collapse into one op") | ||
| 415 | + self.assertEqual(_count(gm.graph, _CAT), 0) | ||
| 416 | + self.assertEqual(_count(gm.graph, _WHERE), 0, "where should be eliminated too") | ||
| 417 | + self.assertEqual(len(ops[0].args[_A_MASKS]), 1, "one mask must be registered only once") | ||
| 418 | + self.assertEqual(list(ops[0].args[_A_MASK_IDX]), [0] * 4) | ||
| 419 | + | ||
| 420 | + expected = torch.cat([_masked_slice_ref(wide, mask, o, w) | ||
| 421 | + for o, w in zip(offsets, widths)], dim=-1) | ||
| 422 | + self._assert_bitwise_equal(expected, gm(wide, mask), | ||
| 423 | + f"masked slices (dynamic={dynamic})") | ||
| 424 | + | ||
| 425 | + | ||
| 426 | + | ||
| 427 | + def test_interleaved_masked_and_bare_collapse(self, rows, dtype): | ||
| 428 | + """cat_124 prototype: masked and bare slices strictly alternating. | ||
| 429 | + | ||
| 430 | + Bare-slice-only matching merges nothing here, since no two bare slices are | ||
| 431 | + adjacent. Covering masked segments joins the run and is the main gain. | ||
| 432 | + """ | ||
| 433 | + wide = self._wide(rows, dtype) | ||
| 434 | + mask = self._mask(rows) | ||
| 435 | + | ||
| 436 | + def fn(x, m): | ||
| 437 | + parts = [] | ||
| 438 | + for i, (off, w) in enumerate(zip(_ALT_OFFSETS, _ALT_WIDTHS)): | ||
| 439 | + parts.append(_masked_slice(x, m, off, w) if i % 2 == 0 | ||
| 440 | + else _SLICE(x, -1, off, off + w)) | ||
| 441 | + return _CAT(parts, -1) | ||
| 442 | + | ||
| 443 | + gm = self._run_pass(fn, wide, mask) | ||
| 444 | + ops = _op_nodes(gm.graph) | ||
| 445 | + | ||
| 446 | + self.assertEqual(len(ops), 1, "an alternating layout should form one run") | ||
| 447 | + self.assertEqual(len(ops[0].args[_A_OFFSETS]), len(_ALT_OFFSETS)) | ||
| 448 | + self.assertEqual(list(ops[0].args[_A_MASK_IDX]), [0, -1, 0, -1, 0, -1, 0, -1], | ||
| 449 | + "masked and bare segments must each be recorded correctly") | ||
| 450 | + | ||
| 451 | + expected = torch.cat([ | ||
| 452 | + _masked_slice_ref(wide, mask, off, w) if i % 2 == 0 | ||
| 453 | + else wide[..., off:off + w] | ||
| 454 | + for i, (off, w) in enumerate(zip(_ALT_OFFSETS, _ALT_WIDTHS)) | ||
| 455 | + ], dim=-1) | ||
| 456 | + self._assert_bitwise_equal(expected, gm(wide, mask), "masked and bare slices alternating") | ||
| 457 | + | ||
| 458 | + | ||
| 459 | + | ||
| 460 | + def test_mask_reshaped_before_where_is_matched(self, rows, dtype): | ||
| 461 | + """Broadcasts of the condition (reshape/expand to width) must be stripped.""" | ||
| 462 | + wide = self._wide(rows, dtype) | ||
| 463 | + mask = self._mask(rows) | ||
| 464 | + | ||
| 465 | + def fn(x, m): | ||
| 466 | + parts = [] | ||
| 467 | + for off, w in ((100, 16), (500, 32)): | ||
| 468 | + cond = torch.ops.aten.expand.default( | ||
| 469 | + torch.ops.aten.reshape.default(m, [-1, 1]), [x.shape[0], w] | ||
| 470 | + ) | ||
| 471 | + zero = _FULL([x.shape[0], w], 0, dtype=x.dtype, device=x.device) | ||
| 472 | + parts.append(_WHERE(cond, zero, _SLICE(x, -1, off, off + w))) | ||
| 473 | + return _CAT(parts, -1) | ||
| 474 | + | ||
| 475 | + gm = self._run_pass(fn, wide, mask) | ||
| 476 | + ops = _op_nodes(gm.graph) | ||
| 477 | + | ||
| 478 | + self.assertEqual(len(ops), 1, "a broadcast condition must still count as a row mask") | ||
| 479 | + self.assertEqual(ops[0].args[_A_MASKS][0].op, "placeholder", | ||
| 480 | + "the registered mask must be the one before broadcast") | ||
| 481 | + expected = torch.cat([_masked_slice_ref(wide, mask, 100, 16), | ||
| 482 | + _masked_slice_ref(wide, mask, 500, 32)], dim=-1) | ||
| 483 | + self._assert_bitwise_equal(expected, gm(wide, mask), "broadcast condition") | ||
| 484 | + | ||
| 485 | + | ||
| 486 | + | ||
| 487 | + def test_mask_chain_stops_at_deepest_row_mask(self, rows, dtype): | ||
| 488 | + """Broadcast stripping must stop at the deepest [rows, 1]. | ||
| 489 | + | ||
| 490 | + The producer here is [1, 1], so stripping all the way down lands on a node | ||
| 491 | + whose row count does not match and drops the segment; the intermediate | ||
| 492 | + [rows, 1] is the real row mask. | ||
| 493 | + """ | ||
| 494 | + wide = self._wide(rows, dtype) | ||
| 495 | + seed = self._mask(1) | ||
| 496 | + | ||
| 497 | + def fn(x, s): | ||
| 498 | + row = torch.ops.aten.expand.default(s, [x.shape[0], 1]) | ||
| 499 | + parts = [] | ||
| 500 | + for off, w in ((100, 16), (500, 32)): | ||
| 501 | + cond = torch.ops.aten.expand.default(row, [x.shape[0], w]) | ||
| 502 | + zero = _FULL([x.shape[0], w], 0, dtype=x.dtype, device=x.device) | ||
| 503 | + parts.append(_WHERE(cond, zero, _SLICE(x, -1, off, off + w))) | ||
| 504 | + return _CAT(parts, -1) | ||
| 505 | + | ||
| 506 | + gm = self._run_pass(fn, wide, seed) | ||
| 507 | + ops = _op_nodes(gm.graph) | ||
| 508 | + | ||
| 509 | + self.assertEqual(len(ops), 1, "a qualifying row mask mid-chain must not drop the segment") | ||
| 510 | + self.assertEqual(len(ops[0].args[_A_MASKS]), 1, "one mask must be registered only once") | ||
| 511 | + recorded = ops[0].args[_A_MASKS][0] | ||
| 512 | + self.assertEqual(tuple(recorded.meta["val"].shape), (rows, 1), | ||
| 513 | + "the registered mask must be [rows, 1]") | ||
| 514 | + self.assertNotEqual(recorded.op, "placeholder", | ||
| 515 | + "a [1, 1] graph input is not a row mask and must not be registered") | ||
| 516 | + | ||
| 517 | + row = seed.expand(rows, 1) | ||
| 518 | + expected = torch.cat([_masked_slice_ref(wide, row, 100, 16), | ||
| 519 | + _masked_slice_ref(wide, row, 500, 32)], dim=-1) | ||
| 520 | + self._assert_bitwise_equal(expected, gm(wide, seed), "scalar broadcast mask") | ||
| 521 | + | ||
| 522 | + | ||
| 523 | + | ||
| 524 | + def test_two_masks_registered_separately(self, rows, dtype): | ||
| 525 | + wide = self._wide(rows, dtype) | ||
| 526 | + m0, m1 = self._mask(rows), self._mask(rows) | ||
| 527 | + | ||
| 528 | + def fn(x, a, b): | ||
| 529 | + return _CAT([_masked_slice(x, a, 100, 16), _masked_slice(x, b, 500, 32), | ||
| 530 | + _masked_slice(x, a, 900, 16)], -1) | ||
| 531 | + | ||
| 532 | + gm = self._run_pass(fn, wide, m0, m1) | ||
| 533 | + ops = _op_nodes(gm.graph) | ||
| 534 | + | ||
| 535 | + self.assertEqual(len(ops), 1) | ||
| 536 | + self.assertEqual(len(ops[0].args[_A_MASKS]), 2, "two distinct masks must each be registered") | ||
| 537 | + self.assertEqual(list(ops[0].args[_A_MASK_IDX]), [0, 1, 0], "mask indices must be reused") | ||
| 538 | + expected = torch.cat([_masked_slice_ref(wide, m0, 100, 16), | ||
| 539 | + _masked_slice_ref(wide, m1, 500, 32), | ||
| 540 | + _masked_slice_ref(wide, m0, 900, 16)], dim=-1) | ||
| 541 | + self._assert_bitwise_equal(expected, gm(wide, m0, m1), "two masks") | ||
| 542 | + | ||
| 543 | + # ------------------------------------------------------------------ | ||
| 544 | + # multi-source: one concat often mixes several model inputs | ||
| 545 | + # ------------------------------------------------------------------ | ||
| 546 | + | ||
| 547 | + | ||
| 548 | + def test_two_bases_merge_into_one_run(self, rows, dtype): | ||
| 549 | + """Slices of different bases can share one op; each source costs one pointer arg.""" | ||
| 550 | + a, b = self._wide(rows, dtype), self._wide(rows, dtype) | ||
| 551 | + | ||
| 552 | + def fn(x, y): | ||
| 553 | + return _CAT([ | ||
| 554 | + _SLICE(x, -1, 100, 116), _SLICE(x, -1, 900, 916), | ||
| 555 | + _SLICE(y, -1, 500, 532), _SLICE(y, -1, 700, 732), | ||
| 556 | + ], -1) | ||
| 557 | + | ||
| 558 | + gm = self._run_pass(fn, a, b) | ||
| 559 | + ops = _op_nodes(gm.graph) | ||
| 560 | + | ||
| 561 | + self.assertEqual(len(ops), 1, "a contiguous multi-source run should collapse into one op") | ||
| 562 | + self.assertEqual(len(ops[0].args[_A_SRCS]), 2, "both sources must be registered") | ||
| 563 | + self.assertEqual(list(ops[0].args[_A_SRC_IDX]), [0, 0, 1, 1], "source indices must match") | ||
| 564 | + expected = torch.cat([a[..., 100:116], a[..., 900:916], | ||
| 565 | + b[..., 500:532], b[..., 700:732]], dim=-1) | ||
| 566 | + self._assert_bitwise_equal(expected, gm(a, b), "two base tensors") | ||
| 567 | + | ||
| 568 | + | ||
| 569 | + | ||
| 570 | + def test_source_cap_splits_run(self, rows, dtype): | ||
| 571 | + a, b = self._wide(rows, dtype), self._wide(rows, dtype) | ||
| 572 | + | ||
| 573 | + def fn(x, y): | ||
| 574 | + return _CAT([ | ||
| 575 | + _SLICE(x, -1, 100, 116), _SLICE(x, -1, 900, 916), | ||
| 576 | + _SLICE(y, -1, 500, 532), _SLICE(y, -1, 700, 732), | ||
| 577 | + ], -1) | ||
| 578 | + | ||
| 579 | + gm = self._run_pass(fn, a, b, max_sources=1) | ||
| 580 | + ops = _op_nodes(gm.graph) | ||
| 581 | + | ||
| 582 | + self.assertEqual(len(ops), 2, "with a source cap of 1 the run must split per base") | ||
| 583 | + for node in ops: | ||
| 584 | + self.assertEqual(len(node.args[_A_SRCS]), 1) | ||
| 585 | + expected = torch.cat([a[..., 100:116], a[..., 900:916], | ||
| 586 | + b[..., 500:532], b[..., 700:732]], dim=-1) | ||
| 587 | + self._assert_bitwise_equal(expected, gm(a, b), "source cap split") | ||
| 588 | + | ||
| 589 | + | ||
| 590 | + | ||
| 591 | + def test_mask_cap_splits_run(self, rows, dtype): | ||
| 592 | + wide = self._wide(rows, dtype) | ||
| 593 | + m0, m1 = self._mask(rows), self._mask(rows) | ||
| 594 | + | ||
| 595 | + def fn(x, a, b): | ||
| 596 | + return _CAT([_masked_slice(x, a, 100, 16), _masked_slice(x, a, 300, 16), | ||
| 597 | + _masked_slice(x, b, 500, 32), _masked_slice(x, b, 700, 32)], -1) | ||
| 598 | + | ||
| 599 | + gm = self._run_pass(fn, wide, m0, m1, max_masks=1) | ||
| 600 | + ops = _op_nodes(gm.graph) | ||
| 601 | + | ||
| 602 | + self.assertEqual(len(ops), 2, "with a mask cap of 1 the run must split per mask") | ||
| 603 | + for node in ops: | ||
| 604 | + self.assertEqual(len(node.args[_A_MASKS]), 1) | ||
| 605 | + expected = torch.cat([_masked_slice_ref(wide, m0, 100, 16), | ||
| 606 | + _masked_slice_ref(wide, m0, 300, 16), | ||
| 607 | + _masked_slice_ref(wide, m1, 500, 32), | ||
| 608 | + _masked_slice_ref(wide, m1, 700, 32)], dim=-1) | ||
| 609 | + self._assert_bitwise_equal(expected, gm(wide, m0, m1), "mask cap split") | ||
| 610 | + | ||
| 611 | + | ||
| 612 | + | ||
| 613 | + def test_masks_disabled_falls_back_to_bare_slices(self, rows, dtype): | ||
| 614 | + wide = self._wide(rows, dtype) | ||
| 615 | + mask = self._mask(rows) | ||
| 616 | + | ||
| 617 | + def fn(x, m): | ||
| 618 | + return _CAT([_masked_slice(x, m, 100, 16), _masked_slice(x, m, 500, 32)], -1) | ||
| 619 | + | ||
| 620 | + gm = self._run_pass(fn, wide, mask, max_masks=0) | ||
| 621 | + self.assertEqual(len(_op_nodes(gm.graph)), 0, "no rewrite when masks are disabled") | ||
| 622 | + | ||
| 623 | + # ------------------------------------------------------------------ | ||
| 624 | + # slices mixed with other inputs: only contiguous runs merge, order is preserved | ||
| 625 | + # ------------------------------------------------------------------ | ||
| 626 | + | ||
| 627 | + | ||
| 628 | + def test_slices_mixed_with_dense_input(self, rows, dtype): | ||
| 629 | + wide = self._wide(rows, dtype) | ||
| 630 | + dense = torch.randn((rows, 64), dtype=torch.float16, device=torch.device("npu")) | ||
| 631 | + widths = (16,) * len(_W16_OFFSETS) | ||
| 632 | + | ||
| 633 | + def fn(x, d): | ||
| 634 | + parts = [_SLICE(x, -1, off, off + w) for off, w in zip(_W16_OFFSETS, widths)] | ||
| 635 | + return _CAT(parts + [torch.ops.aten.relu.default(d)], -1) | ||
| 636 | + | ||
| 637 | + gm = self._run_pass(fn, wide, dense) | ||
| 638 | + | ||
| 639 | + self.assertEqual(len(_op_nodes(gm.graph)), 1, "the slice run should collapse into one op") | ||
| 640 | + self.assertEqual(_count(gm.graph, _CAT), 1, "outer cat must stay to join the dense input") | ||
| 641 | + expected = torch.cat( | ||
| 642 | + [_col_concat_ref(wide, _W16_OFFSETS, widths), torch.relu(dense)], dim=-1 | ||
| 643 | + ) | ||
| 644 | + self._assert_bitwise_equal(expected, gm(wide, dense), "slice run + dense input") | ||
| 645 | + | ||
| 646 | + | ||
| 647 | + | ||
| 648 | + def test_interleaved_slices_split_into_runs(self, rows, dtype): | ||
| 649 | + wide = self._wide(rows, dtype) | ||
| 650 | + dense = torch.randn((rows, 8), dtype=torch.float16, device=torch.device("npu")) | ||
| 651 | + | ||
| 652 | + def fn(x, d): | ||
| 653 | + return _CAT([ | ||
| 654 | + _SLICE(x, -1, 100, 116), | ||
| 655 | + _SLICE(x, -1, 500, 516), | ||
| 656 | + torch.ops.aten.relu.default(d), | ||
| 657 | + _SLICE(x, -1, 900, 916), | ||
| 658 | + _SLICE(x, -1, 1300, 1316), | ||
| 659 | + ], -1) | ||
| 660 | + | ||
| 661 | + gm = self._run_pass(fn, wide, dense) | ||
| 662 | + | ||
| 663 | + self.assertEqual(len(_op_nodes(gm.graph)), 2, "each slice run collapses into its own op") | ||
| 664 | + expected = torch.cat([ | ||
| 665 | + wide[..., 100:116], wide[..., 500:516], torch.relu(dense), | ||
| 666 | + wide[..., 900:916], wide[..., 1300:1316], | ||
| 667 | + ], dim=-1) | ||
| 668 | + self._assert_bitwise_equal(expected, gm(wide, dense), "slices split apart") | ||
| 669 | + | ||
| 670 | + | ||
| 671 | + | ||
| 672 | + def test_segment_cap_splits_run(self, rows, dtype): | ||
| 673 | + wide = self._wide(rows, dtype) | ||
| 674 | + widths = (128,) * len(_W128_OFFSETS) | ||
| 675 | + | ||
| 676 | + def fn(x): | ||
| 677 | + return _col_concat(x, _W128_OFFSETS, widths) | ||
| 678 | + | ||
| 679 | + gm = self._run_pass(fn, wide, max_segments=4) | ||
| 680 | + ops = _op_nodes(gm.graph) | ||
| 681 | + | ||
| 682 | + self.assertEqual(len(ops), 4, "15 segments capped at 4 per group make 4 groups") | ||
| 683 | + for node in ops: | ||
| 684 | + self.assertLessEqual(len(node.args[_A_OFFSETS]), 4, "group exceeds the segment cap") | ||
| 685 | + self._assert_bitwise_equal(_col_concat_ref(wide, _W128_OFFSETS, widths), | ||
| 686 | + gm(wide), "segment cap split") | ||
| 687 | + | ||
| 688 | + # ------------------------------------------------------------------ | ||
| 689 | + # profitability: merging costs one dispatch, so a segment paying less than two loses | ||
| 690 | + # ------------------------------------------------------------------ | ||
| 691 | + | ||
| 692 | + | ||
| 693 | + def test_whole_inputs_alone_not_rewritten(self, rows, dtype): | ||
| 694 | + """aclnnCat reads contiguous inputs directly; folding them in adds a dispatch.""" | ||
| 695 | + a = self._wide(rows, dtype, cols=64) | ||
| 696 | + b = self._wide(rows, dtype, cols=32) | ||
| 697 | + | ||
| 698 | + def fn(x, y): | ||
| 699 | + return _CAT([x, y], -1) | ||
| 700 | + | ||
| 701 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, a, b).graph)), 0, | ||
| 702 | + "a plain concat of two dense inputs must not be rewritten") | ||
| 703 | + | ||
| 704 | + | ||
| 705 | + | ||
| 706 | + def test_whole_input_joins_paying_run(self, rows, dtype): | ||
| 707 | + """A whole input saves nothing alone, but rides along a run that already pays.""" | ||
| 708 | + wide = self._wide(rows, dtype) | ||
| 709 | + dense = self._wide(rows, dtype, cols=64) | ||
| 710 | + | ||
| 711 | + def fn(x, d): | ||
| 712 | + return _CAT([_SLICE(x, -1, 100, 116), d, _SLICE(x, -1, 900, 916)], -1) | ||
| 713 | + | ||
| 714 | + gm = self._run_pass(fn, wide, dense) | ||
| 715 | + ops = _op_nodes(gm.graph) | ||
| 716 | + | ||
| 717 | + self.assertEqual(len(ops), 1, "two slices already pay off, so the whole input can join") | ||
| 718 | + self.assertEqual(_count(gm.graph, _CAT), 0) | ||
| 719 | + expected = torch.cat([wide[..., 100:116], dense, wide[..., 900:916]], dim=-1) | ||
| 720 | + self._assert_bitwise_equal(expected, gm(wide, dense), "whole input between slices") | ||
| 721 | + | ||
| 722 | + | ||
| 723 | + | ||
| 724 | + def test_single_masked_slice_with_whole_input_not_rewritten(self, rows, dtype): | ||
| 725 | + """Only one segment costs a dispatch, so the rewrite gains nothing.""" | ||
| 726 | + wide = self._wide(rows, dtype) | ||
| 727 | + mask = self._mask(rows) | ||
| 728 | + dense = self._wide(rows, dtype, cols=64) | ||
| 729 | + | ||
| 730 | + def fn(x, m, d): | ||
| 731 | + return _CAT([_masked_slice(x, m, 100, 16), d], -1) | ||
| 732 | + | ||
| 733 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide, mask, dense).graph)), 0) | ||
| 734 | + | ||
| 735 | + # ------------------------------------------------------------------ | ||
| 736 | + # must reject: a false match silently miscomputes, far worse than not optimizing | ||
| 737 | + # ------------------------------------------------------------------ | ||
| 738 | + | ||
| 739 | + | ||
| 740 | + def test_single_slice_not_rewritten(self, rows, dtype): | ||
| 741 | + """One slice means one dispatch before and after, so there is no gain.""" | ||
| 742 | + wide = self._wide(rows, dtype) | ||
| 743 | + dense = torch.randn((rows, 16), dtype=torch.float16, device=torch.device("npu")) | ||
| 744 | + | ||
| 745 | + def fn(x, d): | ||
| 746 | + return _CAT([_SLICE(x, -1, 100, 116), torch.ops.aten.relu.default(d)], -1) | ||
| 747 | + | ||
| 748 | + gm = self._run_pass(fn, wide, dense) | ||
| 749 | + self.assertEqual(len(_op_nodes(gm.graph)), 0) | ||
| 750 | + | ||
| 751 | + | ||
| 752 | + | ||
| 753 | + def test_row_direction_cat_not_rewritten(self, rows, dtype): | ||
| 754 | + wide = self._wide(rows, dtype) | ||
| 755 | + | ||
| 756 | + def fn(x): | ||
| 757 | + return _CAT([_SLICE(x, -1, 0, 16), _SLICE(x, -1, 64, 80)], 0) | ||
| 758 | + | ||
| 759 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide).graph)), 0) | ||
| 760 | + | ||
| 761 | + | ||
| 762 | + | ||
| 763 | + def test_slice_on_other_dim_not_rewritten(self, rows, dtype): | ||
| 764 | + """The slice axis differs from the concat axis, so these are not column segments.""" | ||
| 765 | + wide = self._wide(rows, dtype) | ||
| 766 | + | ||
| 767 | + def fn(x): | ||
| 768 | + return _CAT([_SLICE(x, 0, 0, 8), _SLICE(x, 0, 8, 16)], -1) | ||
| 769 | + | ||
| 770 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide).graph)), 0) | ||
| 771 | + | ||
| 772 | + | ||
| 773 | + | ||
| 774 | + def test_strided_slice_not_rewritten(self, rows, dtype): | ||
| 775 | + wide = self._wide(rows, dtype) | ||
| 776 | + | ||
| 777 | + def fn(x): | ||
| 778 | + return _CAT([_SLICE(x, -1, 0, 32, 2), _SLICE(x, -1, 64, 96, 2)], -1) | ||
| 779 | + | ||
| 780 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide).graph)), 0, | ||
| 781 | + "strided slices are not fixed contiguous segments, must be rejected") | ||
| 782 | + | ||
| 783 | + | ||
| 784 | + | ||
| 785 | + def test_mixed_dtype_not_rewritten(self, rows, dtype): | ||
| 786 | + """cat promotes mismatched dtypes, the op does not, so leave it to the original.""" | ||
| 787 | + a, b = self._wide(rows, 'float16'), self._wide(rows, 'float32') | ||
| 788 | + | ||
| 789 | + def fn(x, y): | ||
| 790 | + return _CAT([_SLICE(x, -1, 0, 16), _SLICE(y, -1, 0, 16)], -1) | ||
| 791 | + | ||
| 792 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, a, b).graph)), 0) | ||
| 793 | + | ||
| 794 | + | ||
| 795 | + | ||
| 796 | + def test_three_dim_not_rewritten(self, rows, dtype): | ||
| 797 | + """The op only supports 2D; higher rank goes back to the original cat.""" | ||
| 798 | + x = torch.randn((rows, 4, 256), dtype=torch.float16, device=torch.device("npu")) | ||
| 799 | + | ||
| 800 | + def fn(t): | ||
| 801 | + return _CAT([_SLICE(t, -1, 0, 16), _SLICE(t, -1, 64, 80)], -1) | ||
| 802 | + | ||
| 803 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, x).graph)), 0) | ||
| 804 | + | ||
| 805 | + | ||
| 806 | + | ||
| 807 | + def test_computed_base_not_rewritten(self, rows, dtype): | ||
| 808 | + """Skip computed bases so they are not forced to materialize early. | ||
| 809 | + | ||
| 810 | + In the target case the wide table is a model input and already landed. For a | ||
| 811 | + computed tensor, realize_input in lowering would demand a contiguous buffer | ||
| 812 | + first, which can block fusion on the producer side. | ||
| 813 | + """ | ||
| 814 | + wide = self._wide(rows, dtype) | ||
| 815 | + | ||
| 816 | + def fn(x): | ||
| 817 | + base = torch.ops.aten.mul.Tensor(x, 2.0) | ||
| 818 | + return _CAT([_SLICE(base, -1, 0, 16), _SLICE(base, -1, 64, 80)], -1) | ||
| 819 | + | ||
| 820 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide).graph)), 0, | ||
| 821 | + "a base that is not a graph input must fall back to the original cat") | ||
| 822 | + | ||
| 823 | + | ||
| 824 | + | ||
| 825 | + def test_masked_computed_base_not_rewritten(self, rows, dtype): | ||
| 826 | + """The selected value of a masked segment must also come from a graph input.""" | ||
| 827 | + wide = self._wide(rows, dtype) | ||
| 828 | + mask = self._mask(rows) | ||
| 829 | + | ||
| 830 | + def fn(x, m): | ||
| 831 | + base = torch.ops.aten.mul.Tensor(x, 2.0) | ||
| 832 | + return _CAT([_masked_slice(base, m, 100, 16), | ||
| 833 | + _masked_slice(base, m, 500, 32)], -1) | ||
| 834 | + | ||
| 835 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide, mask).graph)), 0) | ||
| 836 | + | ||
| 837 | + | ||
| 838 | + | ||
| 839 | + def test_elementwise_mask_not_rewritten(self, rows, dtype): | ||
| 840 | + """An elementwise mask is not row-wise, so the op cannot express it.""" | ||
| 841 | + wide = self._wide(rows, dtype) | ||
| 842 | + | ||
| 843 | + def fn(x, m0, m1): | ||
| 844 | + zero0 = _FULL([x.shape[0], 16], 0, dtype=x.dtype, device=x.device) | ||
| 845 | + zero1 = _FULL([x.shape[0], 16], 0, dtype=x.dtype, device=x.device) | ||
| 846 | + return _CAT([_WHERE(m0, zero0, _SLICE(x, -1, 100, 116)), | ||
| 847 | + _WHERE(m1, zero1, _SLICE(x, -1, 500, 516))], -1) | ||
| 848 | + | ||
| 849 | + wide_mask0 = torch.randint(0, 2, (rows, 16), device=torch.device("npu"), | ||
| 850 | + dtype=torch.bool) | ||
| 851 | + wide_mask1 = torch.randint(0, 2, (rows, 16), device=torch.device("npu"), | ||
| 852 | + dtype=torch.bool) | ||
| 853 | + gm = self._run_pass(fn, wide, wide_mask0, wide_mask1) | ||
| 854 | + self.assertEqual(len(_op_nodes(gm.graph)), 0, "an elementwise mask must be rejected") | ||
| 855 | + | ||
| 856 | + | ||
| 857 | + | ||
| 858 | + def test_nonzero_fill_not_rewritten(self, rows, dtype): | ||
| 859 | + wide = self._wide(rows, dtype) | ||
| 860 | + mask = self._mask(rows) | ||
| 861 | + | ||
| 862 | + def fn(x, m): | ||
| 863 | + parts = [] | ||
| 864 | + for off, w in ((100, 16), (500, 32)): | ||
| 865 | + one = _FULL([x.shape[0], w], 1, dtype=x.dtype, device=x.device) | ||
| 866 | + parts.append(_WHERE(m, one, _SLICE(x, -1, off, off + w))) | ||
| 867 | + return _CAT(parts, -1) | ||
| 868 | + | ||
| 869 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide, mask).graph)), 0) | ||
| 870 | + | ||
| 871 | + | ||
| 872 | + | ||
| 873 | + def test_where_between_two_slices_not_rewritten(self, rows, dtype): | ||
| 874 | + """Selecting between two slices reads two places, not zero-fill; unsupported.""" | ||
| 875 | + wide = self._wide(rows, dtype) | ||
| 876 | + mask = self._mask(rows) | ||
| 877 | + | ||
| 878 | + def fn(x, m): | ||
| 879 | + return _CAT([ | ||
| 880 | + _WHERE(m, _SLICE(x, -1, 100, 116), _SLICE(x, -1, 200, 216)), | ||
| 881 | + _WHERE(m, _SLICE(x, -1, 500, 516), _SLICE(x, -1, 600, 616)), | ||
| 882 | + ], -1) | ||
| 883 | + | ||
| 884 | + self.assertEqual(len(_op_nodes(self._run_pass(fn, wide, mask).graph)), 0) | ||
| 885 | + | ||
| 886 | + | ||
| 887 | + | ||
| 888 | + def test_slice_shared_with_other_consumer_is_kept(self, rows, dtype): | ||
| 889 | + wide = self._wide(rows, dtype) | ||
| 890 | + | ||
| 891 | + def fn(x): | ||
| 892 | + a = _SLICE(x, -1, 55818, 55834) | ||
| 893 | + b = _SLICE(x, -1, 56436, 56452) | ||
| 894 | + # a feeds both the cat and a pointwise op, the form Inductor fuses. | ||
| 895 | + return _CAT([a, b], -1), torch.ops.aten.mul.Tensor(a, 2.0) | ||
| 896 | + | ||
| 897 | + gm = self._run_pass(fn, wide) | ||
| 898 | + self.assertEqual(len(_op_nodes(gm.graph)), 1, "the cat path should still be rewritten") | ||
| 899 | + self.assertGreaterEqual(_count(gm.graph, _SLICE), 1, | ||
| 900 | + "a slice used elsewhere must not be deleted") | ||
| 901 | + expected = fn(wide) | ||
| 902 | + actual = gm(wide) | ||
| 903 | + self._assert_bitwise_equal(expected[0], actual[0], "cat result with a shared slice") | ||
| 904 | + self._assert_bitwise_equal(expected[1], actual[1], "other consumer of a shared slice") | ||
| 905 | + | ||
| 906 | + # ------------------------------------------------------------------ | ||
| 907 | + # end to end; needs the flag on, otherwise pass and lowering are not registered | ||
| 908 | + # ------------------------------------------------------------------ | ||
| 909 | + | ||
| 910 | + | ||
| 911 | + | ||
| 912 | + | ||
| 913 | + def test_compile_end_to_end(self, rows, dtype, dynamic): | ||
| 914 | + """The dynamic case marks only batch as symbolic and cannot use | ||
| 915 | + ``torch.compile(dynamic=True)``: that also symbolizes the column count, and | ||
| 916 | + the pass needs a static last dim to read constant offsets, so it silently | ||
| 917 | + falls back to aten.cat. Values stay correct but the pass is not covered. | ||
| 918 | + """ | ||
| 919 | + widths = (16,) * len(_W16_OFFSETS) | ||
| 920 | + wide = self._wide(rows, dtype) | ||
| 921 | + | ||
| 922 | + def fn(x): | ||
| 923 | + return _col_concat_ref(x, _W16_OFFSETS, widths) | ||
| 924 | + | ||
| 925 | + with torch.no_grad(), _pass_spy() as stats: | ||
| 926 | + compiled = torch.compile(fn, backend="inductor", dynamic=False) | ||
| 927 | + if dynamic: | ||
| 928 | + torch._dynamo.mark_dynamic(wide, 0) | ||
| 929 | + self._assert_bitwise_equal(fn(wide), compiled(wide), "compiled artifact") | ||
| 930 | + self.assertGreater(stats["nodes"], 0, | ||
| 931 | + f"pass did not fire (dynamic={dynamic}), still aten.cat") | ||
| 932 | + if dynamic: | ||
| 933 | + # another batch must reuse the same artifact. never 1: dims 0/1 are | ||
| 934 | + # hard-specialized, backed symbols are [2, inf), so 1 recompiles. | ||
| 935 | + other = self._wide(rows * 2, dtype) | ||
| 936 | + torch._dynamo.mark_dynamic(other, 0) | ||
| 937 | + self._assert_bitwise_equal(fn(other), compiled(other), "dynamic batch artifact") | ||
| 938 | + | ||
| 939 | + | ||
| 940 | + | ||
| 941 | + | ||
| 942 | + def test_compile_masked_end_to_end(self, rows, dtype): | ||
| 943 | + wide = self._wide(rows, dtype) | ||
| 944 | + mask = self._mask(rows) | ||
| 945 | + plan = ((100, 16), (500, 32), (900, 64), (1300, 16)) | ||
| 946 | + | ||
| 947 | + def fn(x, m): | ||
| 948 | + return torch.cat([_masked_slice_ref(x, m, off, w) for off, w in plan], dim=-1) | ||
| 949 | + | ||
| 950 | + with torch.no_grad(), _pass_spy() as stats: | ||
| 951 | + compiled = torch.compile(fn, backend="inductor", dynamic=False) | ||
| 952 | + self._assert_bitwise_equal(fn(wide, mask), compiled(wide, mask), "masked artifact") | ||
| 953 | + self.assertGreater(stats["nodes"], 0, "masked segments were not rewritten") | ||
| 954 | + | ||
| 955 | + | ||
| 956 | + | ||
| 957 | + | ||
| 958 | + def test_compile_aliased_masks_end_to_end(self, rows, dtype): | ||
| 959 | + """Two mask nodes landing on the same buffer must still compile. | ||
| 960 | + | ||
| 961 | + One row mask is squeezed and reshaped twice; squeeze is not a stripped | ||
| 962 | + broadcast, so both chains keep a reshape node and the pass registers two | ||
| 963 | + masks. In lowering both views point at the same ComputedBuffer, and | ||
| 964 | + def_kernel registers args by buffer name, so the entries overwrite each | ||
| 965 | + other, the first arg is left undefined, and the rendered kernel raises | ||
| 966 | + NameError. is_live_type in the production model has this exact shape. | ||
| 967 | + """ | ||
| 968 | + wide = self._wide(rows, dtype) | ||
| 969 | + flag = self._mask(rows) | ||
| 970 | + plan = ((100, 16), (500, 32), (900, 64), (1300, 16)) | ||
| 971 | + | ||
| 972 | + def fn(x, f): | ||
| 973 | + cond = torch.logical_not(f) | ||
| 974 | + m0 = cond.squeeze().reshape(-1, 1) | ||
| 975 | + m1 = cond.squeeze().reshape(-1, 1) | ||
| 976 | + picks = (m0, m1, m1, m0) | ||
| 977 | + return torch.cat( | ||
| 978 | + [_masked_slice_ref(x, m, off, w) | ||
| 979 | + for m, (off, w) in zip(picks, plan)], dim=-1) | ||
| 980 | + | ||
| 981 | + with torch.no_grad(), _pass_spy() as stats: | ||
| 982 | + compiled = torch.compile(fn, backend="inductor", dynamic=False) | ||
| 983 | + self._assert_bitwise_equal(fn(wide, flag), compiled(wide, flag), | ||
| 984 | + "aliased masks artifact") | ||
| 985 | + self.assertGreater(stats["nodes"], 0, "aliased mask segments were not rewritten") | ||
| 986 | + | ||
| 987 | + # ------------------------------------------------------------------ | ||
| 988 | + # input dedup in lowering, pinned here since the case above needs the whole chain | ||
| 989 | + # ------------------------------------------------------------------ | ||
| 990 | + def test_dedup_inputs_merges_aliased_buffers(self): | ||
| 991 | + """Duplicate inputs on one buffer merge, indices are remapped, and -1 stays.""" | ||
| 992 | + first, second = _StubInput("buf5"), _StubInput("buf5") | ||
| 993 | + nodes, indices = _dedup_inputs([first, second], [0, -1, 1, 1, 0]) | ||
| 994 | + self.assertEqual(nodes, [first]) | ||
| 995 | + self.assertEqual(indices, [0, -1, 0, 0, 0]) | ||
| 996 | + | ||
| 997 | + def test_dedup_inputs_keeps_distinct_buffers(self): | ||
| 998 | + first, second = _StubInput("buf5"), _StubInput("buf7") | ||
| 999 | + nodes, indices = _dedup_inputs([first, second], [1, 0, -1]) | ||
| 1000 | + self.assertEqual(nodes, [first, second]) | ||
| 1001 | + self.assertEqual(indices, [1, 0, -1]) | ||
| 1002 | + | ||
| 1003 | + def test_dedup_inputs_rejects_same_buffer_different_layout(self): | ||
| 1004 | + """Same name, different layouts fight over one arg slot; caller must fall back.""" | ||
| 1005 | + broadcast = _StubInput("buf5", stride=(0, 1)) | ||
| 1006 | + contiguous = _StubInput("buf5", stride=(1, 1)) | ||
| 1007 | + self.assertEqual(_dedup_inputs([broadcast, contiguous], [0, 1]), (None, None)) | ||
| 1008 | + | ||
| 1009 | + | ||
| 1010 | +instantiate_parametrized_tests(TestMultiSliceConcatPass) | ||
| 1011 | + | ||
| 1012 | + | ||
| 1013 | +if __name__ == "__main__": | ||
| 1014 | + run_tests() | ||
| @@ -0,0 +1,394 @@ | |||
| 1 | +import torch | ||
| 2 | +import torch.fx as fx | ||
| 3 | +from torch.fx.experimental.proxy_tensor import make_fx | ||
| 4 | +from torch.fx.passes.shape_prop import ShapeProp | ||
| 5 | +from torch.testing._internal.common_utils import run_tests, parametrize, instantiate_parametrized_tests | ||
| 6 | +from testutils import TestUtils | ||
| 7 | +import torch_npu | ||
| 8 | +import torch_npu._inductor | ||
| 9 | +from torch_npu._inductor.fx_passes.ascend_custom_passes.ascend_graph_pass import ( | ||
| 10 | + stack_sum_to_add_chain_pass, | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +# From real model output code: a [B, 69876] fp16 wide feature table, cut into fixed-width | ||
| 15 | +# column blocks that are stacked and summed. All offsets are compile-time constants. | ||
| 16 | +_WIDE_COLS = 69876 | ||
| 17 | +_GROUP_W128_OFFSETS = (55834, 56452, 45421, 44203, 44744, 45962, 60741, 61328, | ||
| 18 | + 62267, 65602, 66495, 66956, 67445, 68021, 69060) | ||
| 19 | +_USER_W128_OFFSETS = (55580, 59302) | ||
| 20 | +_GROUP_W16_OFFSETS = (55818, 56436, 45405, 44187, 44728, 45946, 61312, 62251, | ||
| 21 | + 69044, 68199, 68221, 69822) | ||
| 22 | +# Widest stack pooling in the model (28-way); used to stress accumulation error. | ||
| 23 | +_GROUP_W8_OFFSETS = (48025, 47595, 46868, 47890, 48779, 49346, 47769, 49082, | ||
| 24 | + 49180, 48337, 48467, 48597, 48402, 48532, 48714, 49240, | ||
| 25 | + 48070, 48204, 48119, 11076, 14257, 18643, 11922, 15103, | ||
| 26 | + 19489, 12752, 15933, 20319) | ||
| 27 | +# fp16 relative precision, used as the magnitude of one ulp. | ||
| 28 | +_FP16_EPS = 2 ** -10 | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def _count(graph, target): | ||
| 32 | + return len([n for n in graph.nodes if n.op == "call_function" and n.target == target]) | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def _fm_pool(wide, offsets, width): | ||
| 36 | + """Reproduce the [aten.slice, aten.stack] -> sum combination from output code.""" | ||
| 37 | + slices = [ | ||
| 38 | + torch.ops.aten.slice.Tensor(wide, 1, off, off + width) for off in offsets | ||
| 39 | + ] | ||
| 40 | + cat = torch.ops.aten.cat.default(slices, 0) | ||
| 41 | + view = torch.ops.aten.reshape.default(cat, [len(offsets), wide.shape[0], width]) | ||
| 42 | + return torch.ops.aten.sum.dim_IntList(view, [0]) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def _fm_pool_ref(wide, offsets, width): | ||
| 46 | + return torch.stack([wide[:, off:off + width] for off in offsets]).sum(0) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +class ViewCatSumModel(torch.nn.Module): | ||
| 50 | + """Stack form after Inductor simplification: cat(dim=0) -> reshape -> sum(dim=0).""" | ||
| 51 | + | ||
| 52 | + def forward(self, t1, t2, t3): | ||
| 53 | + cat = torch.ops.aten.cat.default([t1, t2, t3], 0) | ||
| 54 | + view = torch.ops.aten.reshape.default(cat, [3, t1.shape[0], t1.shape[1]]) | ||
| 55 | + return torch.ops.aten.sum.dim_IntList(view, [0]) | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +class UnsqueezeCatSumModel(torch.nn.Module): | ||
| 59 | + """Original stack form: unsqueeze -> cat -> sum.""" | ||
| 60 | + | ||
| 61 | + def forward(self, t1, t2, t3): | ||
| 62 | + cat = torch.ops.aten.cat.default( | ||
| 63 | + [ | ||
| 64 | + torch.ops.aten.unsqueeze.default(t1, 0), | ||
| 65 | + torch.ops.aten.unsqueeze.default(t2, 0), | ||
| 66 | + torch.ops.aten.unsqueeze.default(t3, 0), | ||
| 67 | + ], | ||
| 68 | + 0, | ||
| 69 | + ) | ||
| 70 | + return torch.ops.aten.sum.dim_IntList(cat, [0]) | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +class TestStackSumPass(TestUtils): | ||
| 74 | + def _assert_close(self, expected, actual, dtype): | ||
| 75 | + # At magnitude ~6000 one fp16 ulp is already several units, so compare relative error. | ||
| 76 | + tol = 1e-2 if dtype in ('float16', 'bfloat16') else 1e-4 | ||
| 77 | + self.assertEqual(expected, actual, atol=tol, rtol=tol) | ||
| 78 | + | ||
| 79 | + def _trace(self, model, *tensors): | ||
| 80 | + gm = fx.symbolic_trace(model) | ||
| 81 | + ShapeProp(gm).propagate(*tensors) | ||
| 82 | + return gm | ||
| 83 | + | ||
| 84 | + def _run_pass(self, model, *tensors): | ||
| 85 | + gm = self._trace(model, *tensors) | ||
| 86 | + stack_sum_to_add_chain_pass(gm.graph) | ||
| 87 | + gm.recompile() | ||
| 88 | + return gm | ||
| 89 | + | ||
| 90 | + | ||
| 91 | + | ||
| 92 | + def test_view_cat_sum_rewritten(self, shape, dtype): | ||
| 93 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(3)] | ||
| 94 | + model = ViewCatSumModel() | ||
| 95 | + gm = self._run_pass(model, *tensors) | ||
| 96 | + | ||
| 97 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0, "cat should be eliminated") | ||
| 98 | + self.assertEqual(_count(gm.graph, torch.ops.aten.sum.dim_IntList), 0, "sum should be eliminated") | ||
| 99 | + self.assertEqual(_count(gm.graph, torch.ops.aten.add.Tensor), 2, "3 inputs should give 2 adds") | ||
| 100 | + self._assert_close(model(*tensors), gm(*tensors), dtype) | ||
| 101 | + | ||
| 102 | + | ||
| 103 | + | ||
| 104 | + def test_low_precision_accumulates_in_fp32(self, shape, dtype): | ||
| 105 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(3)] | ||
| 106 | + gm = self._run_pass(ViewCatSumModel(), *tensors) | ||
| 107 | + | ||
| 108 | + casts = [ | ||
| 109 | + n for n in gm.graph.nodes | ||
| 110 | + if n.op == "call_function" and n.target == torch.ops.prims.convert_element_type.default | ||
| 111 | + ] | ||
| 112 | + self.assertEqual(len(casts), 4, "3 upcasts plus 1 downcast back to the original dtype") | ||
| 113 | + self.assertEqual(gm(*tensors).dtype, torch.float16, "output dtype should be unchanged") | ||
| 114 | + | ||
| 115 | + | ||
| 116 | + | ||
| 117 | + def test_unsqueeze_cat_sum_rewritten(self, shape, dtype): | ||
| 118 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(3)] | ||
| 119 | + model = UnsqueezeCatSumModel() | ||
| 120 | + gm = self._run_pass(model, *tensors) | ||
| 121 | + | ||
| 122 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0, "cat should be eliminated") | ||
| 123 | + self.assertEqual(_count(gm.graph, torch.ops.aten.add.Tensor), 2) | ||
| 124 | + self._assert_close(model(*tensors), gm(*tensors), dtype) | ||
| 125 | + | ||
| 126 | + | ||
| 127 | + | ||
| 128 | + def test_keepdim_preserves_shape(self, shape, dtype): | ||
| 129 | + class M(torch.nn.Module): | ||
| 130 | + def forward(self, t1, t2): | ||
| 131 | + cat = torch.ops.aten.cat.default([t1, t2], 0) | ||
| 132 | + view = torch.ops.aten.reshape.default(cat, [2, t1.shape[0], t1.shape[1]]) | ||
| 133 | + return torch.ops.aten.sum.dim_IntList(view, [0], True) | ||
| 134 | + | ||
| 135 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(2)] | ||
| 136 | + model = M() | ||
| 137 | + gm = self._run_pass(model, *tensors) | ||
| 138 | + | ||
| 139 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0) | ||
| 140 | + self._assert_close(model(*tensors), gm(*tensors), dtype) | ||
| 141 | + | ||
| 142 | + | ||
| 143 | + | ||
| 144 | + def test_slices_of_one_base(self, shape, dtype): | ||
| 145 | + """Real model form: column slices of a single wide table, stacked and summed.""" | ||
| 146 | + class M(torch.nn.Module): | ||
| 147 | + def forward(self, wide): | ||
| 148 | + slices = [ | ||
| 149 | + torch.ops.aten.slice.Tensor(wide, 1, off, off + 4) | ||
| 150 | + for off in (0, 8, 20, 36) | ||
| 151 | + ] | ||
| 152 | + cat = torch.ops.aten.cat.default(slices, 0) | ||
| 153 | + view = torch.ops.aten.reshape.default(cat, [4, wide.shape[0], 4]) | ||
| 154 | + return torch.ops.aten.sum.dim_IntList(view, [0]) | ||
| 155 | + | ||
| 156 | + wide = self._generate_tensor((8, 64), dtype) | ||
| 157 | + model = M() | ||
| 158 | + gm = self._run_pass(model, wide) | ||
| 159 | + | ||
| 160 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0, "cat should be eliminated") | ||
| 161 | + self.assertEqual(_count(gm.graph, torch.ops.aten.slice.Tensor), 4, "slices should survive as views") | ||
| 162 | + self._assert_close(model(wide), gm(wide), dtype) | ||
| 163 | + | ||
| 164 | + # ------------------------------------------------------------------ | ||
| 165 | + # Real model scenario: symbolic batch, fp16, real offsets, traced by make_fx so nodes carry | ||
| 166 | + # meta['val']. Covers the propagate_fake_tensor path that ShapeProp never reaches. | ||
| 167 | + # ------------------------------------------------------------------ | ||
| 168 | + def _wide_tensor(self, rows, dtype, scale=0.1): | ||
| 169 | + return torch.randn( | ||
| 170 | + (rows, _WIDE_COLS), dtype=eval('torch.' + dtype), device=torch.device("npu") | ||
| 171 | + ) * scale | ||
| 172 | + | ||
| 173 | + | ||
| 174 | + def _fp32_golden(wide, offsets, width): | ||
| 175 | + return _fm_pool_ref(wide.float(), offsets, width) | ||
| 176 | + | ||
| 177 | + def _assert_not_worse_than(self, golden, baseline, candidate, note): | ||
| 178 | + """Candidate error against the fp32 golden must not exceed the baseline, plus one fp16 ulp. | ||
| 179 | + | ||
| 180 | + Both paths accumulate in fp32 and round once on write-back, so results are normally | ||
| 181 | + bitwise-identical; the extra ulp tolerates last-bit differences from accumulation order. | ||
| 182 | + """ | ||
| 183 | + err_base = (baseline.float() - golden).abs().max().item() | ||
| 184 | + err_cand = (candidate.float() - golden).abs().max().item() | ||
| 185 | + one_ulp = golden.abs().max().item() * _FP16_EPS | ||
| 186 | + self.assertLessEqual( | ||
| 187 | + err_cand, | ||
| 188 | + err_base + one_ulp, | ||
| 189 | + f"{note}: rewritten error {err_cand:.3e} exceeds the original {err_base:.3e} plus one ulp {one_ulp:.3e}", | ||
| 190 | + ) | ||
| 191 | + self.assertEqual(baseline, candidate, atol=one_ulp, rtol=_FP16_EPS) | ||
| 192 | + | ||
| 193 | + | ||
| 194 | + | ||
| 195 | + def test_accuracy_28way_not_worse_than_unrewritten(self, rows, dtype): | ||
| 196 | + """Graph-level A/B against the fp32 result: both runs execute the same operators, so any | ||
| 197 | + error difference can only come from this pass. Comparing against eager cannot isolate it. | ||
| 198 | + """ | ||
| 199 | + wide = self._wide_tensor(rows, dtype, scale=1.0) | ||
| 200 | + | ||
| 201 | + def fn(x): | ||
| 202 | + return _fm_pool(x, _GROUP_W8_OFFSETS, 8) | ||
| 203 | + | ||
| 204 | + baseline_gm = make_fx(fn, tracing_mode="symbolic")(wide) | ||
| 205 | + rewritten_gm = make_fx(fn, tracing_mode="symbolic")(wide) | ||
| 206 | + stack_sum_to_add_chain_pass(rewritten_gm.graph) | ||
| 207 | + rewritten_gm.recompile() | ||
| 208 | + | ||
| 209 | + self.assertEqual(_count(baseline_gm.graph, torch.ops.aten.cat.default), 1, "baseline graph should keep the cat") | ||
| 210 | + self.assertEqual(_count(rewritten_gm.graph, torch.ops.aten.cat.default), 0, "rewritten graph should eliminate the cat") | ||
| 211 | + | ||
| 212 | + golden = self._fp32_golden(wide, _GROUP_W8_OFFSETS, 8) | ||
| 213 | + self._assert_not_worse_than(golden, baseline_gm(wide), rewritten_gm(wide), "28-way stack pooling") | ||
| 214 | + | ||
| 215 | + def _make_fx_symbolic(self, fn, *tensors): | ||
| 216 | + gm = make_fx(fn, tracing_mode="symbolic")(*tensors) | ||
| 217 | + stack_sum_to_add_chain_pass(gm.graph) | ||
| 218 | + gm.recompile() | ||
| 219 | + return gm | ||
| 220 | + | ||
| 221 | + | ||
| 222 | + | ||
| 223 | + def test_real_model_fm_pooling(self, rows, dtype): | ||
| 224 | + """Prototype of output code L20616: 15-way width=128 column slice stack sum.""" | ||
| 225 | + wide = self._wide_tensor(rows, dtype) | ||
| 226 | + | ||
| 227 | + def fn(x): | ||
| 228 | + return _fm_pool(x, _GROUP_W128_OFFSETS, 128) | ||
| 229 | + | ||
| 230 | + gm = self._make_fx_symbolic(fn, wide) | ||
| 231 | + | ||
| 232 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0, "cat should be eliminated") | ||
| 233 | + self.assertEqual(_count(gm.graph, torch.ops.aten.sum.dim_IntList), 0, "sum should be eliminated") | ||
| 234 | + self.assertEqual(_count(gm.graph, torch.ops.aten.add.Tensor), 14, "15 inputs should give 14 adds") | ||
| 235 | + self._assert_close(_fm_pool_ref(wide, _GROUP_W128_OFFSETS, 128), gm(wide), dtype) | ||
| 236 | + | ||
| 237 | + | ||
| 238 | + | ||
| 239 | + def test_real_model_mul_of_two_pools(self, rows, dtype): | ||
| 240 | + """Prototype of triton_per_fused_mul_stack_sum_16: two pooling results multiplied.""" | ||
| 241 | + wide = self._wide_tensor(rows, dtype) | ||
| 242 | + | ||
| 243 | + def fn(x): | ||
| 244 | + user = _fm_pool(x, _USER_W128_OFFSETS, 128) | ||
| 245 | + group = _fm_pool(x, _GROUP_W128_OFFSETS, 128) | ||
| 246 | + return torch.ops.aten.mul.Tensor(user, group) | ||
| 247 | + | ||
| 248 | + gm = self._make_fx_symbolic(fn, wide) | ||
| 249 | + | ||
| 250 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 0, "both cats should be eliminated") | ||
| 251 | + self.assertEqual(_count(gm.graph, torch.ops.aten.add.Tensor), 15, "(2-1) + (15-1)") | ||
| 252 | + expected = ( | ||
| 253 | + _fm_pool_ref(wide, _USER_W128_OFFSETS, 128) | ||
| 254 | + * _fm_pool_ref(wide, _GROUP_W128_OFFSETS, 128) | ||
| 255 | + ) | ||
| 256 | + self._assert_close(expected, gm(wide), dtype) | ||
| 257 | + | ||
| 258 | + | ||
| 259 | + | ||
| 260 | + def test_rewritten_nodes_carry_fake_meta(self, rows, dtype): | ||
| 261 | + """Inserted nodes must carry meta['val'], otherwise Inductor lowering gets no shape.""" | ||
| 262 | + wide = self._wide_tensor(rows, dtype) | ||
| 263 | + | ||
| 264 | + def fn(x): | ||
| 265 | + return _fm_pool(x, _GROUP_W16_OFFSETS, 16) | ||
| 266 | + | ||
| 267 | + gm = self._make_fx_symbolic(fn, wide) | ||
| 268 | + | ||
| 269 | + inserted = [ | ||
| 270 | + n for n in gm.graph.nodes | ||
| 271 | + if n.op == "call_function" | ||
| 272 | + and n.target in (torch.ops.aten.add.Tensor, | ||
| 273 | + torch.ops.prims.convert_element_type.default) | ||
| 274 | + ] | ||
| 275 | + self.assertTrue(inserted, "add chain and cast nodes should be inserted") | ||
| 276 | + for node in inserted: | ||
| 277 | + self.assertIn('val', node.meta, f"{node.name} is missing meta['val']") | ||
| 278 | + output_node = [n for n in gm.graph.nodes if n.op == "output"][0] | ||
| 279 | + result = output_node.args[0] | ||
| 280 | + while isinstance(result, (list, tuple)): | ||
| 281 | + result = result[0] | ||
| 282 | + self.assertEqual(result.meta['val'].dtype, torch.float16, "output dtype should stay fp16") | ||
| 283 | + | ||
| 284 | + | ||
| 285 | + | ||
| 286 | + def test_symbolic_batch_is_matched(self, rows, dtype): | ||
| 287 | + """Shape comparison must hold for a symbolic batch, or the pass never fires when dynamic.""" | ||
| 288 | + wide = self._wide_tensor(rows, dtype) | ||
| 289 | + | ||
| 290 | + def fn(x): | ||
| 291 | + return _fm_pool(x, _GROUP_W16_OFFSETS, 16) | ||
| 292 | + | ||
| 293 | + traced = make_fx(fn, tracing_mode="symbolic")(wide) | ||
| 294 | + # SymInt is neither hashable nor directly comparable, so only check per-dim types. | ||
| 295 | + has_symbolic_dim = any( | ||
| 296 | + isinstance(dim, torch.SymInt) | ||
| 297 | + for node in traced.graph.nodes | ||
| 298 | + if 'val' in node.meta and hasattr(node.meta['val'], 'shape') | ||
| 299 | + for dim in node.meta['val'].shape | ||
| 300 | + ) | ||
| 301 | + self.assertTrue( | ||
| 302 | + has_symbolic_dim, | ||
| 303 | + "trace must carry symbolic shapes, otherwise this case does not exercise dynamic shape", | ||
| 304 | + ) | ||
| 305 | + | ||
| 306 | + stack_sum_to_add_chain_pass(traced.graph) | ||
| 307 | + traced.recompile() | ||
| 308 | + self.assertEqual(_count(traced.graph, torch.ops.aten.cat.default), 0) | ||
| 309 | + | ||
| 310 | + other = self._wide_tensor(rows * 2, dtype) | ||
| 311 | + self._assert_close(_fm_pool_ref(other, _GROUP_W16_OFFSETS, 16), traced(other), dtype) | ||
| 312 | + | ||
| 313 | + | ||
| 314 | + | ||
| 315 | + def test_multi_user_cat_not_rewritten(self, shape, dtype): | ||
| 316 | + class M(torch.nn.Module): | ||
| 317 | + def forward(self, t1, t2): | ||
| 318 | + cat = torch.ops.aten.cat.default([t1, t2], 0) | ||
| 319 | + view = torch.ops.aten.reshape.default(cat, [2, t1.shape[0], t1.shape[1]]) | ||
| 320 | + return torch.ops.aten.sum.dim_IntList(view, [0]), cat | ||
| 321 | + | ||
| 322 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(2)] | ||
| 323 | + gm = self._run_pass(M(), *tensors) | ||
| 324 | + | ||
| 325 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 1, "cat has another user, so it must not be rewritten") | ||
| 326 | + self.assertEqual(_count(gm.graph, torch.ops.aten.add.Tensor), 0) | ||
| 327 | + | ||
| 328 | + | ||
| 329 | + | ||
| 330 | + def test_sum_on_other_dim_not_rewritten(self, shape, dtype): | ||
| 331 | + class M(torch.nn.Module): | ||
| 332 | + def forward(self, t1, t2): | ||
| 333 | + cat = torch.ops.aten.cat.default([t1, t2], 0) | ||
| 334 | + view = torch.ops.aten.reshape.default(cat, [2, t1.shape[0], t1.shape[1]]) | ||
| 335 | + return torch.ops.aten.sum.dim_IntList(view, [1]) | ||
| 336 | + | ||
| 337 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(2)] | ||
| 338 | + gm = self._run_pass(M(), *tensors) | ||
| 339 | + | ||
| 340 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 1, "a sum over a non-stack axis must not be rewritten") | ||
| 341 | + | ||
| 342 | + | ||
| 343 | + | ||
| 344 | + def test_integer_dtype_not_rewritten(self, shape, dtype): | ||
| 345 | + """Integer sum promotes to int64, which an add chain does not, so leave it untouched.""" | ||
| 346 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(3)] | ||
| 347 | + gm = self._run_pass(ViewCatSumModel(), *tensors) | ||
| 348 | + | ||
| 349 | + self.assertEqual(_count(gm.graph, torch.ops.aten.cat.default), 1) | ||
| 350 | + | ||
| 351 | + | ||
| 352 | + | ||
| 353 | + def test_compile_cases(self, shape, dtype): | ||
| 354 | + def op_calc(t1, t2, t3): | ||
| 355 | + return torch.stack([t1, t2, t3]).sum(0) | ||
| 356 | + | ||
| 357 | + tensors = [self._generate_tensor(shape, dtype) for _ in range(3)] | ||
| 358 | + std_result = op_calc(*tensors) | ||
| 359 | + with torch.no_grad(): | ||
| 360 | + compiled = torch.compile(op_calc, backend="inductor") | ||
| 361 | + self._assert_close(std_result, compiled(*tensors), dtype) | ||
| 362 | + | ||
| 363 | + | ||
| 364 | + | ||
| 365 | + def test_compile_real_pattern_dynamic_batch(self, rows, dtype): | ||
| 366 | + def op_calc(x): | ||
| 367 | + return _fm_pool_ref(x, _GROUP_W16_OFFSETS, 16) | ||
| 368 | + | ||
| 369 | + wide = self._wide_tensor(rows, dtype) | ||
| 370 | + with torch.no_grad(): | ||
| 371 | + compiled = torch.compile(op_calc, backend="inductor", dynamic=True) | ||
| 372 | + self._assert_close(op_calc(wide), compiled(wide), dtype) | ||
| 373 | + other = self._wide_tensor(rows * 2, dtype) | ||
| 374 | + self._assert_close(op_calc(other), compiled(other), dtype) | ||
| 375 | + | ||
| 376 | + | ||
| 377 | + | ||
| 378 | + def test_compile_accuracy_against_fp32_golden(self, rows, dtype): | ||
| 379 | + """Eager fp16 error is the yardstick, so the bound holds across data magnitudes.""" | ||
| 380 | + def op_calc(x): | ||
| 381 | + return _fm_pool_ref(x, _GROUP_W8_OFFSETS, 8) | ||
| 382 | + | ||
| 383 | + wide = self._wide_tensor(rows, dtype, scale=1.0) | ||
| 384 | + golden = self._fp32_golden(wide, _GROUP_W8_OFFSETS, 8) | ||
| 385 | + with torch.no_grad(): | ||
| 386 | + compiled = torch.compile(op_calc, backend="inductor") | ||
| 387 | + self._assert_not_worse_than(golden, op_calc(wide), compiled(wide), "end-to-end 28-way pooling") | ||
| 388 | + | ||
| 389 | + | ||
| 390 | +instantiate_parametrized_tests(TestStackSumPass) | ||
| 391 | + | ||
| 392 | + | ||
| 393 | +if __name__ == "__main__": | ||
| 394 | + run_tests() | ||
| @@ -136,6 +136,7 @@ def _load_triton_backend(): | |||
| 136 | _register_npu_inductor_flex_attention, | 136 | _register_npu_inductor_flex_attention, |
| 137 | _register_npu_inductor_grouped_mm, | 137 | _register_npu_inductor_grouped_mm, |
| 138 | _register_npu_inductor_mm, | 138 | _register_npu_inductor_mm, |
| 139 | + _register_npu_inductor_multi_slice_concat, | ||
| 139 | _validate_device, | 140 | _validate_device, |
| 140 | patch_flex_attention, | 141 | patch_flex_attention, |
| 141 | ) | 142 | ) |
| @@ -216,6 +217,8 @@ def _load_triton_backend(): | |||
| 216 | _register_npu_inductor_addmm() | 217 | _register_npu_inductor_addmm() |
| 217 | _register_npu_inductor_bmm() | 218 | _register_npu_inductor_bmm() |
| 218 | _register_npu_inductor_grouped_mm() | 219 | _register_npu_inductor_grouped_mm() |
| 220 | + if npu_config.enable_multi_slice_concat: | ||
| 221 | + _register_npu_inductor_multi_slice_concat() | ||
| 219 | 222 | ||
| 220 | _register_npu_inductor_flex_attention() | 223 | _register_npu_inductor_flex_attention() |
| 221 | 224 | ||
| @@ -430,6 +430,22 @@ enable_fused_matmul_relu = _parse_bool_env( | |||
| 430 | "TORCHINDUCTOR_ENABLE_FUSED_MATMUL_RELU", False | 430 | "TORCHINDUCTOR_ENABLE_FUSED_MATMUL_RELU", False |
| 431 | ) | 431 | ) |
| 432 | 432 | ||
| 433 | +# multi_slice_concat_pass: rewrite a run of constant-offset column slices feeding | ||
| 434 | +# one cat into npu_ext::multi_slice_concat, so a single kernel replaces the N Slice | ||
| 435 | +# copies aclnnCat needs. Gated off until validated on hardware; the rewrite is pure | ||
| 436 | +# data movement and cannot affect numerics. | ||
| 437 | +enable_multi_slice_concat = _parse_bool_env( | ||
| 438 | + "TORCHINDUCTOR_ENABLE_MULTI_SLICE_CONCAT", False | ||
| 439 | +) | ||
| 440 | + | ||
| 441 | +# grouped_matmul_fusion_pass: merge the independent small GEMMs feeding one cat into | ||
| 442 | +# npu_grouped_matmul. Gated off until validated on the target model; the rewrite keeps | ||
| 443 | +# every GEMM's operands intact but the kernel accumulates differently, so results are | ||
| 444 | +# close but not bit-exact. | ||
| 445 | +enable_grouped_matmul_fusion = _parse_bool_env( | ||
| 446 | + "TORCHINDUCTOR_ENABLE_GROUPED_MATMUL_FUSION", False | ||
| 447 | +) | ||
| 448 | + | ||
| 433 | # permute_continous_reduction: when enabled, detects the "permute contiguous reduction" | 449 | # permute_continous_reduction: when enabled, detects the "permute contiguous reduction" |
| 434 | # pattern (a non-reduction axis sitting between two reduction axes in stride order) | 450 | # pattern (a non-reduction axis sitting between two reduction axes in stride order) |
| 435 | # and applies special handling: selects the permute axis as a tiling axis, uses NDDMA | 451 | # and applies special handling: selects the permute axis as a tiling axis, uses NDDMA |
| @@ -32,7 +32,7 @@ def run_register_post_custom_passes(gm): | |||
| 32 | stable_topological_sort(gm) | 32 | stable_topological_sort(gm) |
| 33 | for level in sorted(FxPassLevel): | 33 | for level in sorted(FxPassLevel): |
| 34 | for fn in ASCEND_CUSTOME_PASS_REGISTER[PassType.POST][level]: | 34 | for fn in ASCEND_CUSTOME_PASS_REGISTER[PassType.POST][level]: |
| 35 | - # 标记了 ignore_inference_check 的 pass 在训练图下也执行。 | 35 | + # a pass marked ignore_inference_check also runs on training graphs. |
| 36 | if inference or getattr(fn, "ignore_inference_check", False): | 36 | if inference or getattr(fn, "ignore_inference_check", False): |
| 37 | fn(gm) | 37 | fn(gm) |
| 38 | 38 | ||