已合并
Add GatherV2 and ScatterElements DSL operators with generated tests #155
Add GatherV2 and ScatterElements DSL operators with generated tests #155
已合并
lds创建于 14 天前
共 8 个文件变更+4372-0
@@ -0,0 +1,70 @@
1+# gather_v2
2+ 
3+## 产品支持情况
4+ 
5+- Ascend 950:支持。
6+ 
7+## 功能说明
8+ 
9+`gather_v2` 沿指定 `axis` 按 `indices` 取出 `x` 的元素,支持批次维、负轴和可选的负索引。实现位于 [`gather_v2.py`](gather_v2.py)。将 `axis` 和 `batch_dims` 归一化后,输出形状为 `x.shape[:axis] + indices.shape[batch_dims:] + x.shape[axis + 1:]`,输出 dtype 和设备与 `x` 相同。
10+ 
11+设索引轴长度为 `A`。默认按 `[0, A)` 判断索引合法性;开启 `negative_index_support` 后,负索引先加一次 `A`。归一化后仍越界的位置输出零,索引轴为空时也输出零。数据通过同位宽整数视图搬运,保留 NaN payload、负零和 complex 位模式;输入不被原地修改,仅支持前向计算。
12+ 
13+## 函数原型
14+ 
15+```python
16+gather_v2(
17+ x: torch.Tensor,
18+ indices: torch.Tensor,
19+ axis: int,
20+ *,
21+ batch_dims: int = 0,
22+ negative_index_support: bool = False,
23+ core_num: int | None = None,
24+ thread_num: int | None = None,
25+) -> torch.Tensor
26+```
27+ 
28+`x` 至少一维,支持 float16、bfloat16、float32、float64、int8、uint8、int16、uint16、int32、int64、bool 和 complex64。`indices` 支持 int32/int64,可为标量;两输入必须连续且位于同一 NPU,`x.requires_grad` 必须为 `False`。
29+ 
30+`axis` 范围为 `[-x.ndim, x.ndim)`。`batch_dims` 允许负值,按 `indices.ndim` 归一化;归一化后须满足 `0 <= batch_dims <= min(axis, indices.ndim)`,两输入前 `batch_dims` 维的形状必须相同。`core_num` 为可选 Vector Core 数上限。`thread_num` 为可选的每核 SIMT 线程数,支持 32、64、128、256、512、1024、2048,其中 2048 按 1024 启动;默认由入口选择。
31+ 
32+## 测试
33+ 
34+配置 CANN 环境并进入已安装 PyTorch、torch_npu、cannbotdsl 和 pytest 的 Python 环境,从仓库根目录执行:
35+ 
36+```bash
37+python -m pytest -q test/gather_v2/test_gather_v2.py
38+```
39+ 
40+默认执行 15 条 NPU 用例和 5 条主机校验。NPU 用例包含原有 10 条指定规格的功能与性能用例,以及批次负索引、空 gather 轴、64 位特殊位模式、DMA 缓存复用与淘汰、短字节行尾块和极值索引 5 条典型回归。NPU 不可用时跳过设备测试。仅运行指定规格用例或主机侧测试可执行:
41+ 
42+```bash
43+python -m pytest -q test/gather_v2/test_gather_v2.py -k test_functional_precision_case
44+python -m pytest -q test/gather_v2/test_gather_v2.py -m "not npu"
45+```
46+ 
47+输入和 CPU 参考结果通过固定随机种子在内存中生成,不保存输入或参考数组。指定规格用例在功能校验通过后,预热 1 次、采集 2 次设备耗时,结果默认写入 pytest 临时目录;可用 `CANNBOT_CASE_RESULTS` 指定本次运行的输出目录。直接运行测试脚本同样执行全部测试,并额外汇总 `performance.csv`。
48+ 
49+## 性能
50+ 
51+以下为 2026-10-14 在 Ascend 950DT、Python 3.12 和同一 CANN 环境下配对采集的结果。Ascend C 与 DSL 使用相同输入;时间为设备侧 Task Duration 的最小值,不包含编译、主机调用和输入搬运耗时。Ascend C kernel 模式采集 31 个样本,ACLNN 模式采集 30 个样本;DSL 每例采集 30 个样本。所有 DSL 用例通过独立 CPU 参考校验。
52+ 
53+`dsl/ascendc = DSL 时间 / Ascend C 时间`,小于 1 表示 DSL 更快。
54+ 
55+| 用例 | Ascend C (μs) | DSL (μs) | dsl/ascendc |
56+|:---|--:|--:|--:|
57+| `gather_v2_0307` | 1.834 | 1.466 | 0.7993 |
58+| `gather_v2_0016` | 17.978 | 13.964 | 0.7767 |
59+| `gather_v2_0052` | 1.544 | 1.422 | 0.9210 |
60+| `gather_v2_0343` | 68.647 | 67.843 | 0.9883 |
61+| `gather_v2_0003` | 1.416 | 1.285 | 0.9075 |
62+| `gather_v2_test_case_0` | 2.660 | 2.462 | 0.9256 |
63+| `gather_v2_test_case_1` | 1.974 | 1.006 | 0.5096 |
64+| `gather_v2_test_case_2` | 1.709 | 1.024 | 0.5992 |
65+| `gather_v2_test_case_3` | 1.992 | 1.074 | 0.5392 |
66+| `gather_v2_test_case_4` | 1.968 | 1.278 | 0.6494 |
67+ 
68+前 5 条 Ascend C 基线使用 `ops-nn` 内核,后 5 条使用 CANN ACLNN 接口;ACLNN 数值仍为设备算子耗时。越界索引按本算子的规则输出零。
69+ 
70+此表使用配对输入及上述采样方式。测试脚本重新生成输入并使用 2 次采样,其单独测量结果不能与表中的 Ascend C 时间直接组合计算比值。
@@ -0,0 +1,67 @@
1+# scatter_elements
2+ 
3+## 产品支持情况
4+ 
5+- Ascend 950:支持。
6+ 
7+## 功能说明
8+ 
9+`scatter_elements` 根据 `indices` 沿指定 `axis` 把 `updates` 写入 `data` 的副本。实现位于 [`scatter_elements.py`](scatter_elements.py)。对于 `indices` 的位置 `p`,目标位置由 `p` 的非索引轴坐标与 `indices[p]` 的索引轴坐标组成。`reduction="none"` 替换目标值,`"add"` 和 `"mul"` 分别在原值上逐次累加、累乘。输出 shape、dtype 和设备与 `data` 相同,输入不被原地修改,仅支持前向计算。
10+ 
11+索引须满足 `0 <= indices[p] < data.shape[axis]`;负索引不做归一化。设备内核会跳过越界值以避免非法访存,但越界值不属于有效输入。替换操作通过整数视图保留原始位模式,包括 NaN payload、负零和 complex 值。
12+ 
13+## 函数原型
14+ 
15+```python
16+scatter_elements(
17+ data: torch.Tensor,
18+ indices: torch.Tensor,
19+ updates: torch.Tensor,
20+ axis: int,
21+ *,
22+ reduction: str = "none",
23+ core_num: int | None = None,
24+ deterministic: bool = False,
25+) -> torch.Tensor
26+```
27+ 
28+三个输入必须连续、位于同一 NPU 且 rank 相同,rank 范围为 1~8;`data` 与 `updates` 的 dtype 相同,二者均不允许 `requires_grad=True`。`indices` 支持 int32/int64。每维须满足 `indices.shape[d] <= updates.shape[d]`,非索引轴还须满足 `indices.shape[d] <= data.shape[d]`。`axis` 范围为 `[-data.ndim, data.ndim)`,`core_num` 为可选 Vector Core 数上限。
29+ 
30+`none` 支持 float16、bfloat16、float32、float64、int8、uint8、int16、int32、int64、bool 和 complex64;`add`、`mul` 支持其中除 float64、complex64 外的类型。空索引返回 `data` 的副本。
31+ 
32+`deterministic=True` 时,重复目标按 `indices` 的展平顺序处理,替换操作最后一次写入生效。默认非确定性替换不保证重复目标的写入顺序,结果应来自该目标的某次合法更新。float32 原子加法的求和顺序也可能与 CPU 顺序参考不同。
33+ 
34+## 测试
35+ 
36+配置 CANN 环境并进入已安装 PyTorch、torch_npu、cannbotdsl 和 pytest 的 Python 环境,从仓库根目录执行:
37+ 
38+```bash
39+python -m pytest -q test/scatter_elements/test_scatter_elements.py
40+```
41+ 
42+默认执行 10 条 NPU 用例和 13 条主机校验。NPU 用例包含原有 5 条指定规格的功能与性能用例,以及原始位模式保留、int64 高位加法与乘法、float16 有序乘法、axis=0 的 float32 确定性加法 5 条典型回归。NPU 不可用时跳过设备测试。仅运行指定规格用例或主机侧测试可执行:
43+ 
44+```bash
45+python -m pytest -q test/scatter_elements/test_scatter_elements.py -k test_functional_precision_case
46+python -m pytest -q test/scatter_elements/test_scatter_elements.py -m "not npu"
47+```
48+ 
49+输入和 CPU 参考结果通过固定随机种子在内存中生成,不保存输入或参考数组。指定规格用例在功能校验通过后,预热 1 次、采集 2 次设备耗时,结果默认写入 pytest 临时目录;可用 `CANNBOT_CASE_RESULTS` 指定本次运行的输出目录。直接运行测试脚本同样执行全部测试,并额外汇总 `performance.csv`。
50+ 
51+## 性能
52+ 
53+以下为 2026-10-14 在 Ascend 950DT、Python 3.12 和同一 CANN 环境下配对采集的结果。Ascend C 与 DSL 使用相同输入;时间为设备侧 Task Duration 的最小值,不包含编译、主机调用和输入搬运耗时。Ascend C kernel 模式采集 31 个样本,ACLNN 模式采集 30 个样本;DSL 每例采集 30 个样本。所有 DSL 用例通过独立 CPU 参考校验。
54+ 
55+`dsl/ascendc = DSL 时间 / Ascend C 时间`,小于 1 表示 DSL 更快。
56+ 
57+| 用例 | Ascend C (μs) | DSL (μs) | dsl/ascendc |
58+|:---|--:|--:|--:|
59+| `scatter_elements_determ_none_008` | 3.980 | 2.780 | 0.6985 |
60+| `scatter_elements_none_027` | 5.662 | 4.474 | 0.7902 |
61+| `scatter_elements_add_014` | 4.580 | 3.544 | 0.7738 |
62+| `aclnnScatter_fp16_int64_nd_infnan_random_000016` | 5.276 | 4.052 | 0.7680 |
63+| `ScatterElements_int64_int32_ND_fuzz_000075` | 4.343 | 3.362 | 0.7741 |
64+ 
65+5 条 Ascend C 基线均使用 `ops-nn` 内核,包括名称含 `aclnnScatter` 的用例。所有索引均已调整到合法范围。
66+ 
67+此表使用配对输入及上述采样方式。测试脚本重新生成输入并使用 2 次采样,其单独测量结果不能与表中的 Ascend C 时间直接组合计算比值。
@@ -43,3 +43,5 @@ operators:
43 - mega_recurrent_kda_replayssm43 - mega_recurrent_kda_replayssm
44 - commit_recurrent_kda_replayssm44 - commit_recurrent_kda_replayssm
45 - quant_mla_prolog45 - quant_mla_prolog
46+ - gather_v2
47+ - scatter_elements
@@ -0,0 +1,568 @@
1+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
2+# This program is free software, you can redistribute it and/or modify it under the terms and conditions of
3+# CANN Open Software License Agreement Version 2.0 (the "License").
4+# Please refer to the License for details. You may not use this file except in compliance with the License.
5+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6+# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7+# See LICENSE in the root of the software repository for the full text of the License.
8+ 
9+"""Host validation and NPU bit-exact tests for the pure DSL GatherV2."""
10+ 
11+import csv
12+import datetime
13+import json
14+import logging
15+import os
16+import sys
17+import tempfile
18+from itertools import product
19+from pathlib import Path
20+ 
21+import pytest
22+import torch
23+ 
24+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "samples" / "gather_v2"))
25+from gather_v2 import GatherV2Tiling, gather_v2
26+ 
27+ 
28+LOGGER = logging.getLogger(__name__)
29+DEVICE_TASK_TYPES = {
30+ "AI_VECTOR_CORE",
31+ "AI_CORE",
32+ "KERNEL_AIVEC",
33+ "KERNEL_AICORE",
34+ "MIX_AIC",
35+ "MIX_AIV",
36+}
37+ 
38+ 
39+def _word_dtype(element_bytes):
40+ return {1: torch.int8, 2: torch.int16, 4: torch.int32, 8: torch.int64}[
41+ element_bytes
42+ ]
43+ 
44+ 
45+def _reference(x, indices, axis, batch_dims=0, negative=False):
46+ axis %= x.ndim
47+ if batch_dims < 0:
48+ batch_dims += indices.ndim
49+ word_dtype = _word_dtype(x.element_size())
50+ raw_x = x.view(word_dtype)
51+ inner_axis_start = axis + 1
52+ shape = (
53+ tuple(x.shape[:axis])
54+ + tuple(indices.shape[batch_dims:])
55+ + tuple(x.shape[inner_axis_start:])
56+ )
57+ output = torch.zeros(shape, dtype=word_dtype)
58+ if not output.numel() or not x.shape[axis]:
59+ return output.view(x.dtype)
60+ for batch in product(*(range(n) for n in x.shape[:batch_dims])):
61+ selected = indices[batch].long().flatten()
62+ if negative:
63+ selected = torch.where(selected < 0, selected + x.shape[axis], selected)
64+ valid = (selected >= 0) & (selected < x.shape[axis])
65+ safe = selected.clamp(0, x.shape[axis] - 1)
66+ chunk = torch.index_select(raw_x[batch], axis - batch_dims, safe)
67+ mask_shape = [1] * chunk.ndim
68+ mask_shape[axis - batch_dims] = selected.numel()
69+ chunk = torch.where(valid.reshape(mask_shape), chunk, torch.zeros_like(chunk))
70+ output[batch] = chunk.reshape(output[batch].shape)
71+ return output.view(x.dtype)
72+ 
73+ 
74+@pytest.fixture(scope="module")
75+def npu_ready():
76+ pytest.importorskip("torch_npu")
77+ if not torch.npu.is_available():
78+ pytest.skip("Ascend NPU is unavailable")
79+ return torch.device("npu:0")
80+ 
81+ 
82+def _assert_npu_exact(x, indices, axis, device, **kwargs):
83+ expected = _reference(
84+ x,
85+ indices,
86+ axis,
87+ kwargs.get("batch_dims", 0),
88+ kwargs.get("negative_index_support", False),
89+ )
90+ actual = gather_v2(x.to(device), indices.to(device), axis, **kwargs).cpu()
91+ assert actual.shape == expected.shape
92+ dtype = _word_dtype(x.element_size())
93+ torch.testing.assert_close(actual.view(dtype), expected.view(dtype), rtol=0, atol=0)
94+ 
95+ 
96+def test_host_batch_shape_and_thread_cap():
97+ t = GatherV2Tiling(
98+ (2, 3, 4, 5),
99+ (2, 7, 9),
100+ -2,
@@ -0,0 +1,455 @@
1+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
2+# This program is free software, you can redistribute it and/or modify it under the terms and conditions of
3+# CANN Open Software License Agreement Version 2.0 (the "License").
4+# Please refer to the License for details. You may not use this file except in compliance with the License.
5+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
6+# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
7+# See LICENSE in the root of the software repository for the full text of the License.
8+ 
9+import csv
10+import datetime
11+import json
12+import logging
13+import os
14+import sys
15+import tempfile
16+from itertools import product
17+from pathlib import Path
18+ 
19+import pytest
20+import torch
21+ 
22+sys.path.insert(
23+ 0, str(Path(__file__).resolve().parents[2] / "samples" / "scatter_elements")
24+)
25+from scatter_elements import ScatterElementsTiling, scatter_elements
26+ 
27+ 
28+LOGGER = logging.getLogger(__name__)
29+DEVICE_TASK_TYPES = {
30+ "AI_VECTOR_CORE",
31+ "AI_CORE",
32+ "KERNEL_AIVEC",
33+ "KERNEL_AICORE",
34+ "MIX_AIC",
35+ "MIX_AIV",
36+}
37+ 
38+ 
39+def test_tiling_and_strides():
40+ tiling = ScatterElementsTiling((2, 3, 5), (2, 2, 4), (2, 3, 5), -2, "add")
41+ assert tiling.axis == 1
42+ assert tiling.data_numel == 30
43+ assert tiling.indices_numel == 16
44+ assert tiling.axis_size == 3
45+ assert tiling.data_strides == (15, 5, 1)
46+ assert tiling.index_strides == (8, 4, 1)
47+ assert tiling.update_strides == (15, 5, 1)
48+ assert len(tiling.stride_parameters) == 36
49+ assert tiling.stride_parameters[24:27] == (2, 2, 4)
50+ assert tiling.stride_parameters[32:36] == (3, 1, 3, 1)
51+ 
52+ 
53+@pytest.mark.parametrize(
54+ "data_shape,index_shape,update_shape,axis,reduction,match",
55+ [
56+ ((), (), (), 0, "none", "rank"),
57+ ((2, 3), (2,), (2, 3), 0, "none", "same rank"),
58+ ((2, 3), (3, 3), (3, 3), 1, "none", "data.shape"),
59+ ((2, 3), (2, 3), (2, 2), 1, "none", "updates.shape"),
60+ ((2, 3), (2, 3), (2, 3), 2, "none", "axis"),
61+ ((2, 3), (2, 3), (2, 3), 1, "mean", "reduction"),
62+ ((2, 0), (2, 1), (2, 1), 1, "none", "cannot be empty"),
63+ ],
64+)
65+def test_invalid_tiling(data_shape, index_shape, update_shape, axis, reduction, match):
66+ with pytest.raises(ValueError, match=match):
67+ ScatterElementsTiling(data_shape, index_shape, update_shape, axis, reduction)
68+ 
69+ 
70+@pytest.mark.parametrize("axis", [True, 1.5, "1"])
71+def test_invalid_axis_type(axis):
72+ with pytest.raises(TypeError, match="axis"):
73+ ScatterElementsTiling((2, 3), (2, 3), (2, 3), axis)
74+ 
75+ 
76+def test_empty_indices_is_valid():
77+ tiling = ScatterElementsTiling((2, 3), (0, 2), (1, 3), 1)
78+ assert tiling.indices_numel == 0
79+ assert tiling.data_numel == 6
80+ 
81+ 
82+def test_overflow():
83+ with pytest.raises(OverflowError, match="64-bit"):
84+ ScatterElementsTiling((2**62, 4), (0, 1), (0, 1), 1)
85+ 
86+ 
87+@pytest.fixture(scope="module")
88+def torch_npu_ready():
89+ pytest.importorskip("torch_npu")
90+ if not torch.npu.is_available():
91+ pytest.skip("Ascend NPU is unavailable")
92+ 
93+ 
94+def reference(data, indices, updates, axis, reduction):
95+ output = data.clone()
96+ if not indices.numel():
97+ return output
98+ for coordinate in product(*(range(size) for size in indices.shape)):
99+ destination = list(coordinate)
100+ destination[axis] = int(indices[coordinate])
101+ destination = tuple(destination)
102+ if reduction == "none":
103+ output[destination] = updates[coordinate]
104+ elif reduction == "add":
105+ output[destination] = output[destination] + updates[coordinate]
106+ else:
107+ output[destination] = output[destination] * updates[coordinate]
108+ return output
109+ 
110+ 
111+@pytest.mark.npu
112+def test_none_preserves_bits(torch_npu_ready):
113+ bits = torch.tensor(
114+ [0x7FC01234, -2147483648, 0x7F800000, -8388608], dtype=torch.int32
115+ )
116+ data = bits.view(torch.float32)
117+ indices = torch.tensor([3, 1], dtype=torch.int64)
118+ update_bits = torch.tensor([0x7FA12345, -2147483648], dtype=torch.int32)
119+ updates = update_bits.view(torch.float32)
120+ actual = scatter_elements(
121+ data.npu(), indices.npu(), updates.npu(), 0, deterministic=True
122+ )
123+ expected = reference(data, indices, updates, 0, "none")
124+ assert torch.equal(actual.cpu().view(torch.int32), expected.view(torch.int32))
125+ 
126+ 
127+@pytest.mark.npu
128+@pytest.mark.parametrize("reduction", ["add", "mul"])
129+def test_int64_reduction_high_bits(torch_npu_ready, reduction):
130+ data = torch.tensor(
131+ [(1 << 63) - 1, -(1 << 63), -1, (1 << 32) - 1, 1 << 40],
132+ dtype=torch.int64,
133+ )
134+ indices = torch.tensor([0, 0, 1, 1, 2, 2, 3, 3, 4, 4], dtype=torch.int64)
135+ updates = torch.tensor(
136+ [-1, 1, (1 << 32) - 1, 2, -(1 << 63), -1, 3, 1 << 40, -1, 2],
137+ dtype=torch.int64,
138+ )
139+ expected = reference(data, indices, updates, 0, reduction)
140+ actual = scatter_elements(
141+ data.npu(), indices.npu(), updates.npu(), 0, reduction=reduction
142+ )
143+ torch.testing.assert_close(actual.cpu(), expected, rtol=0, atol=0)
144+ 
145+ 
146+@pytest.mark.npu
147+@pytest.mark.parametrize("dtype_name", ["float16"])
148+@pytest.mark.parametrize("index_name", ["int32"])
149+@pytest.mark.parametrize("reduction", ["mul"])
150+@pytest.mark.parametrize("deterministic", [True])
151+def test_fractional_negative_ordered_reduction(
152+ torch_npu_ready, dtype_name, index_name, reduction, deterministic
153+):
154+ """Fractional/negative payloads must not pass through integer loop state."""
155+ dtype = getattr(torch, dtype_name)
156+ data = (torch.arange(30).remainder(9).float() / 8 - 0.75).reshape(2, 3, 5).to(dtype)
157+ updates = (
158+ (torch.arange(30).remainder(7).float() / 8 - 0.5).reshape(2, 3, 5).to(dtype)
159+ )
160+ indices = torch.tensor(
161+ [[[1, 1, 0, 2], [1, 0, 0, 2]], [[0, 1, 2, 1], [0, 1, 2, 1]]],
162+ dtype=getattr(torch, index_name),
163+ )
164+ expected = reference(data, indices, updates, 1, reduction)
165+ actual = scatter_elements(
166+ data.npu(),
167+ indices.npu(),
168+ updates.npu(),
169+ 1,
170+ reduction=reduction,
171+ deterministic=deterministic,
172+ )
173+ torch.testing.assert_close(actual.cpu(), expected, rtol=0, atol=0)
174+ 
175+ 
176+@pytest.mark.npu
177+@pytest.mark.parametrize("index_name", ["int64"])
178+@pytest.mark.parametrize("deterministic", [True])
179+def test_axis0_float32_fractional_add(torch_npu_ready, index_name, deterministic):
180+ """Cover deterministic and atomic addition with repeated fractional updates."""
181+ shape = (10, 17, 29)
182+ generator = torch.Generator().manual_seed(2701)
183+ data = torch.randint(-32, 33, shape, generator=generator).float() / 16
184+ updates = torch.randint(-16, 17, shape, generator=generator).float() / 16
185+ indices = torch.randint(
186+ 0, 10, shape, generator=generator, dtype=getattr(torch, index_name)
187+ )
188+ expected = reference(data, indices, updates, 0, "add")
189+ actual = scatter_elements(
190+ data.npu(),
191+ indices.npu(),
192+ updates.npu(),
193+ 0,
194+ reduction="add",
195+ deterministic=deterministic,
196+ )
197+ torch.testing.assert_close(actual.cpu(), expected, rtol=0, atol=0)
198+ 
199+ 
200+# data shape, index shape, update shape, data dtype, index dtype, axis,
201+# reduction and reproducible seed. Indices are generated within axis bounds.
202+_CASES = [
203+ pytest.param(
204+ (10, 38, 26),
205+ (10, 18, 26),
206+ (10, 28, 96),
207+ torch.int32,
208+ torch.int32,
209+ 1,
210+ "none",
211+ 2001,
212+ id="scatter_elements_determ_none_008",
213+ ),
214+ pytest.param(
215+ (1, 3, 8, 16, 5, 3, 4),
216+ (1, 3, 8, 16, 5, 3, 4),
217+ (1, 3, 8, 16, 5, 3, 4),
218+ torch.bfloat16,
219+ torch.int32,
220+ 2,
221+ "none",
222+ 2002,
223+ id="scatter_elements_none_027",
224+ ),
225+ pytest.param(
226+ (10, 2, 18, 26, 5),
227+ (10, 2, 18, 26, 5),
228+ (10, 2, 18, 26, 5),
229+ torch.float32,
230+ torch.int64,
231+ 0,
232+ "add",
233+ 2003,
234+ id="scatter_elements_add_014",
235+ ),
236+ pytest.param(
237+ (5, 9, 15, 4, 6, 6, 11),
238+ (1, 1, 1, 1, 1, 1, 2),
239+ (7, 3, 12, 2, 4, 12, 9),
240+ torch.float16,
241+ torch.int64,
242+ -2,
243+ "none",
244+ 2004,
245+ id="aclnnScatter_fp16_int64_nd_infnan_random_000016",
246+ ),
247+ pytest.param(
248+ (672, 128),
249+ (672, 97),
250+ (672, 97),
251+ torch.int32,
252+ torch.int64,
253+ 1,
254+ "none",
255+ 2005,
256+ id="ScatterElements_int64_int32_ND_fuzz_000075",
257+ ),
258+]
259+ 
260+ 
261+def _check_precision_result(data, indices, updates, axis, reduction, actual):
262+ axis %= data.ndim
263+ assert bool(((indices >= 0) & (indices < data.shape[axis])).all())
264+ assert actual.shape == data.shape and actual.dtype == data.dtype
265+ if reduction == "none":
266+ word = {1: torch.int8, 2: torch.int16, 4: torch.int32, 8: torch.int64}[
267+ data.element_size()
268+ ]
269+ raw_data, raw_updates, raw_actual = [
270+ value.view(word) for value in (data, updates, actual)
271+ ]
272+ choices = {}
273+ for coordinate in product(*(range(size) for size in indices.shape)):
274+ destination = list(coordinate)
275+ destination[axis] = int(indices[coordinate])
276+ choices.setdefault(tuple(destination), set()).add(
277+ int(raw_updates[coordinate])
278+ )
279+ mask = torch.ones(data.shape, dtype=torch.bool)
280+ for destination, legal_values in choices.items():
281+ mask[destination] = False
282+ assert int(raw_actual[destination]) in legal_values
283+ assert torch.equal(raw_actual[mask], raw_data[mask]), "Untouched bits changed"
284+ else:
285+ expected = reference(data, indices, updates, axis, reduction)
286+ torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-6)
287+ 
288+ 
289+@pytest.mark.npu
290+@pytest.mark.parametrize(
291+ "shape,index_shape,update_shape,dtype,index_dtype,axis,reduction,seed", _CASES
292+)
293+def test_functional_precision_case(
294+ torch_npu_ready,
295+ tmp_path,
296+ request,
297+ shape,
298+ index_shape,
299+ update_shape,
300+ dtype,
301+ index_dtype,
302+ axis,
303+ reduction,
304+ seed,
305+):
306+ generator = torch.Generator().manual_seed(seed)
307+ data = _make_precision_values(shape, dtype, generator)
308+ updates = _make_precision_values(update_shape, dtype, generator)
309+ if dtype.is_floating_point and reduction == "none":
310+ for value in (data, updates):
311+ value.reshape(-1)[:4] = torch.tensor(
312+ [float("nan"), float("inf"), -float("inf"), -0.0], dtype=dtype
313+ )
314+ indices = torch.randint(
315+ 0, shape[axis], index_shape, generator=generator, dtype=index_dtype
316+ )
317+ device = [value.npu() for value in (data, indices, updates)]
318+ 
319+ def execute():
320+ return scatter_elements(*device, axis, reduction=reduction, deterministic=False)
321+ 
322+ actual = execute().cpu()
323+ _check_precision_result(data, indices, updates, axis, reduction, actual)
324+ _profile_precision_case(request.node.callspec.id, execute, tmp_path)
325+ 
326+ 
327+def _make_precision_values(shape, dtype, generator):
328+ """Generate values in memory; preserve GatherV2's uint16 raw-bit inputs."""
329+ if dtype.is_floating_point:
330+ return (
331+ torch.randint(-7, 8, shape, generator=generator, dtype=torch.int32).float()
332+ / 8
333+ ).to(dtype)
334+ return torch.randint(-7, 8, shape, generator=generator, dtype=dtype)
335+ 
336+ 
337+def _device_durations(destination):
338+ """Read device durations from the available profiler CSV format."""
339+ candidates = list(destination.rglob("kernel_details.csv"))
340+ if candidates:
341+ evidence = max(candidates, key=lambda path: path.stat().st_mtime)
342+ with evidence.open(encoding="utf-8", newline="") as stream:
343+ records = list(csv.DictReader(stream))
344+ assert records, "Profiler returned no kernel samples"
345+ key = None
346+ for field in records[0]:
347+ if field.strip().lower() in (
348+ "duration(us)",
349+ "duration(μs)",
350+ "duration(µs)",
351+ "duration",
352+ ):
353+ key = field
354+ break
355+ assert key is not None, "Profiler returned no duration column"
356+ durations = [float(row[key]) for row in records]
357+ else:
358+ candidates = list(destination.rglob("op_summary*.csv"))
359+ assert candidates, "No NPU device profiling CSV generated"
360+ evidence = max(candidates, key=lambda path: path.stat().st_mtime)
361+ with evidence.open(encoding="utf-8", newline="") as stream:
362+ durations = [
363+ float(row["Task Duration(us)"])
364+ for row in csv.DictReader(stream)
365+ if row.get("Task Type") in DEVICE_TASK_TYPES
366+ ]
367+ assert durations and all((value > 0 for value in durations))
368+ return (durations, evidence)
369+ 
370+ 
371+def _profile_precision_case(name, execute, tmp_path):
372+ """Warm up once and collect two device executions after functional validation."""
373+ import torch_npu
374+ 
375+ output_root = Path(os.environ.get("CANNBOT_CASE_RESULTS", str(tmp_path)))
376+ destination = output_root / name
377+ destination.mkdir(parents=True, exist_ok=True)
378+ torch.npu.synchronize()
379+ with torch_npu.profiler.profile(
380+ activities=[
381+ torch_npu.profiler.ProfilerActivity.CPU,
382+ torch_npu.profiler.ProfilerActivity.NPU,
383+ ],
384+ schedule=torch_npu.profiler.schedule(wait=0, warmup=1, active=2, repeat=1),
385+ on_trace_ready=torch_npu.profiler.tensorboard_trace_handler(str(destination)),
386+ record_shapes=False,
387+ profile_memory=False,
388+ with_stack=False,
389+ ) as profiler:
390+ for _ in range(3):
391+ output = execute()
392+ torch.npu.synchronize()
393+ profiler.step()
394+ durations, evidence = _device_durations(destination)
395+ result = {
396+ "testcase_name": name,
397+ "dsl_us": sum(durations) / 2,
398+ "functional_validation": "PASS",
399+ "effective_samples": 2,
400+ "device_duration_samples_us": durations,
401+ "profiling_csv": str(evidence),
402+ "output_shape": list(output.shape),
403+ "output_dtype": str(output.dtype),
404+ }
405+ (destination / "result.json").write_text(
406+ json.dumps(result, indent=2), encoding="utf-8"
407+ )
408+ LOGGER.info("%s: PASS, DSL=%.3f us", name, result["dsl_us"])
409+ 
410+ 
411+def _run_tests():
412+ """Run one operator script and summarize its measured cases in a temporary directory."""
413+ logging.basicConfig(level=logging.INFO, format="%(message)s")
414+ script = Path(__file__).resolve()
415+ timestamp = datetime.datetime.now(tz=datetime.timezone.utc).strftime(
416+ "%Y%m%d_%H%M%S_%f"
417+ )
418+ output = (
419+ Path(tempfile.gettempdir())
420+ / "cannbot_operator_tests"
421+ / (script.parent.name + "_" + timestamp)
422+ )
423+ os.environ.setdefault("CANNBOT_CASE_RESULTS", str(output))
424+ result_dir = Path(os.environ["CANNBOT_CASE_RESULTS"])
425+ result_dir.mkdir(parents=True, exist_ok=True)
426+ status = pytest.main([str(script), "-q", "-s", *sys.argv[1:]])
427+ results = []
428+ for case in _CASES:
429+ path = result_dir / case.id / "result.json"
430+ if path.exists():
431+ results.append(json.loads(path.read_text(encoding="utf-8")))
432+ if results:
433+ fields = [
434+ "testcase_name",
435+ "dsl_us",
436+ "functional_validation",
437+ "effective_samples",
438+ ]
439+ with (result_dir / "performance.csv").open(
440+ "w", encoding="utf-8-sig", newline=""
441+ ) as stream:
442+ writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
443+ writer.writeheader()
444+ writer.writerows(results)
445+ LOGGER.info(
446+ "Measured %s/%s cases. Results: %s",
447+ len(results),
448+ len(_CASES),
449+ result_dir / "performance.csv",
450+ )
451+ return status
452+ 
453+ 
454+if __name__ == "__main__":
455+ raise SystemExit(_run_tests())
@@ -281,6 +281,22 @@ flash_mla_with_kvcache:
281 options:281 options:
282 - flash_mla_with_kvcache282 - flash_mla_with_kvcache
283 283 
284+gather_v2:
285+ module: True
286+ src:
287+ - samples/gather_v2
288+ - test/gather_v2
289+ options:
290+ - gather_v2
291+ 
292+scatter_elements:
293+ module: True
294+ src:
295+ - samples/scatter_elements
296+ - test/scatter_elements
297+ options:
298+ - scatter_elements
299+ 
284shared:300shared:
285 module: True301 module: True
286 src:302 src: