CANN ops-nn 非连续 Tensor 详解:shape / strides / offset 三要素表示法与算子支持机制
【免费下载链接】ops-nn本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。项目地址: https://gitcode.com/cann/ops-nn
本篇文章围绕 CANN ops-nn 算子库中"非连续 Tensor(Non-contiguous Tensor)"这一核心概念展开,说明一个 aclTensor 如何通过(shape, strides, offset)三元组在内存中描述不连续的视图(view),并给出两个完整的内存排布实例。读完本文,你将掌握 strides 与 offset 的精确定义、非连续地址的推算方法、通过aclCreateTensor构造非连续 aclTensor 的实战写法,以及 ops-nn 算子内部为支持非连续输入所采用的 Contiguous 转换与 ViewCopy 机制。
一、背景:为什么需要"非连续 Tensor"
在神经网络计算中,张量(Tensor)通常以连续内存(行优先 / Row-Major)的方式存储,即逻辑下标(i0, i1, ..., in-1)对应的元素在内存中是依次相邻的。但实际应用中大量操作会自然产生"视图不连续"的数据,典型场景包括:
- 切片(Slice):从一个大张量中截取一个子块,子块内部各行之间并不相邻;
- 转置(Transpose):交换维度后,原始连续排布在新视图下变成跳跃式访问;
- 步幅采样:如按步长 2 抽帧、跨行读取等。
若每次产生这类视图都物理拷贝一份连续数据,会带来明显的内存与带宽开销。为此,CANN 算子库引入"非连续 Tensor"概念:一个 Tensor 可以通过(shape, strides, offset)表示,用 strides 描述各维度相邻元素的间隔、用 offset 描述首元素相对基地址的偏移,从而在不拷贝数据的前提下描述一个逻辑张量视图。目前大部分算子 API 的输入 aclTensor 都支持这种表示方式。
二、核心三要素:shape、strides、offset
非连续 Tensor 的表示由三个要素共同决定:
| 要素 | 含义 | 取值特点 |
|---|---|---|
| shape | 描述 Tensor 的逻辑形状,即每个维度上的元素个数 | 与连续 Tensor 的 shape 含义完全一致 |
| strides | 描述 Tensor 各维度上相邻两个元素在内存中的间隔(以元素个数计) | 某维度 stride 为 1 时该维度连续;stride 大于 1 时该维度上元素之间存在间隔,即非连续 |
| offset | 表示该 Tensor 的首元素相对基地址addr的偏移(以元素个数计) | 取值为非负整数,指向视图的第一个有效元素 |
其中 strides 是判断连续性的关键:如果在维度 1 上的 stride 为 1,该维度是连续的;如果在维度 0 上的 stride 为 10,那么相邻的元素间隔 10 个元素,即非连续。offset 则解决了"视图不从基地址第 0 个元素开始"的问题,例如从一个大矩阵中间截取子块时,首元素往往并不位于内存起点。
在 strided 视图表示下,逻辑下标(i0, i1, ..., in-1)对应的元素在内存中的位置可表达为:
base_addr + (offset + i0 * strides[0] + i1 * strides[1] + ... + in-1 * strides[n-1]) * 元素大小当所有维度的 strides 恰好满足"行优先连续"的递推关系(最后一维 stride 为 1,前一维 stride 等于后一维 shape 与 stride 的乘积)时,该 Tensor 退化为连续 Tensor,连续 Tensor 可视作 offset 为 0、strides 由 shape 递推得出的特例。
三、示例 1:shape=(6, 5)、strides=(10, 1)、offset=22 的行连续切片
假设底层一块连续内存被组织为一个 10×10 的逻辑矩阵(基地址为addr),现有 Tensor 的shape=(6, 5)、strides=(10, 1)、offset=22,其内存排布如下(深色为有效元素):
| 行\列 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | a0,0 | a0,1 | a0,2 | a0,3 | a0,4 | a0,5 | a0,6 | a0,7 | a0,8 | a0,9 |
| 1 | a1,0 | a1,1 | a1,2 | a1,3 | a1,4 | a1,5 | a1,6 | a1,7 | a1,8 | a1,9 |
| 2 | a2,0 | a2,1 | a2,2 | a2,3 | a2,4 | a2,5 | a2,6 | a2,7 | a2,8 | a2,9 |
| 3 | a3,0 | a3,1 | a3,2 | a3,3 | a3,4 | a3,5 | a3,6 | a3,7 | a3,8 | a3,9 |
| 4 | a4,0 | a4,1 | a4,2 | a4,3 | a4,4 | a4,5 | a4,6 | a4,7 | a4,8 | a4,9 |
| 5 | a5,0 | a5,1 | a5,2 | a5,3 | a5,4 | a5,5 | a5,6 | a5,7 | a5,8 | a5,9 |
| 6 | a6,0 | a6,1 | a6,2 | a6,3 | a6,4 | a6,5 | a6,6 | a6,7 | a6,8 | a6,9 |
| 7 | a7,0 | a7,1 | a7,2 | a7,3 | a7,4 | a7,5 | a7,6 | a7,7 | a7,8 | a7,9 |
| 8 | a8,0 | a8,1 | a8,2 | a8,3 | a8,4 | a8,5 | a8,6 | a8,7 | a8,8 | a8,9 |
| 9 | a9,0 | a9,1 | a9,2 | a9,3 | a9,4 | a9,5 | a9,6 | a9,7 | a9,8 | a9,9 |
该 Tensor 的有效数据即上图的深色位置,具体解读如下:
- offset=22:在 10×10 的连续排布中,第 0 行占 10 个元素(索引 0~9)、第 1 行占 10 个元素(索引 10~19),索引 20、21 对应 a2,0、a2,1,因此首元素 a2,2 的偏移为 22;
- 维度 1 的 stride 为 1:同一行内相邻元素紧挨着存放,所以每行取连续 5 个元素(a2,2 ~ a2,6);
- 维度 0 的 stride 为 10:下一行的首元素与当前行的首元素间隔 10 个元素,即从 a2,2 出发,行号每 +1 需要跳过 10 个元素的位置,依次得到 a3,2、a4,2……直到 a7,2,共 6 行。
这个完整的 Tensor 在内存排布上是不连续的:维度 1 上是连续的(stride=1),但维度 0 上的元素间隔 10 个元素,属于非连续。该视图等价于"从 10×10 矩阵中截取第 2~7 行、第 2~6 列"的切片,且全程无需任何数据拷贝。
四、示例 2:shape=(4, 3)、strides=(20, 2)、offset=22 的跳跃采样视图
再看一个维度 0 和维度 1 都不连续的示例。同样基于 10×10 的逻辑矩阵,现有 Tensor 的shape=(4, 3)、strides=(20, 2)、offset=22,其内存排布如下:
| 行\列 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | a0,0 | a0,1 | a0,2 | a0,3 | a0,4 | a0,5 | a0,6 | a0,7 | a0,8 | a0,9 |
| 1 | a1,0 | a1,1 | a1,2 | a1,3 | a1,4 | a1,5 | a1,6 | a1,7 | a1,8 | a1,9 |
| 2 | a2,0 | a2,1 | a2,2 | a2,3 | a2,4 | a2,5 | a2,6 | a2,7 | a2,8 | a2,9 |
| 3 | a3,0 | a3,1 | a3,2 | a3,3 | a3,4 | a3,5 | a3,6 | a3,7 | a3,8 | a3,9 |
| 4 | a4,0 | a4,1 | a4,2 | a4,3 | a4,4 | a4,5 | a4,6 | a4,7 | a4,8 | a4,9 |
| 5 | a5,0 | a5,1 | a5,2 | a5,3 | a5,4 | a5,5 | a5,6 | a5,7 | a5,8 | a5,9 |
| 6 | a6,0 | a6,1 | a6,2 | a6,3 | a6,4 | a6,5 | a6,6 | a6,7 | a6,8 | a6,9 |
| 7 | a7,0 | a7,1 | a7,2 | a7,3 | a7,4 | a7,5 | a7,6 | a7,7 | a7,8 | a7,9 |
| 8 | a8,0 | a8,1 | a8,2 | a8,3 | a8,4 | a8,5 | a8,6 | a8,7 | a8,8 | a8,9 |
| 9 | a9,0 | a9,1 | a9,2 | a9,3 | a9,4 | a9,5 | a9,6 | a9,7 | a9,8 | a9,9 |
该 Tensor 的有效数据即上图的深色位置,具体解读如下:
- offset=22:与示例 1 相同,首元素为 a2,2;
- 维度 1 的 stride 为 2:同一行内相邻两个有效元素之间间隔 1 个元素,因此每行取 3 个元素,依次为 a2,2、a2,4、a2,6(跳过了列 3、列 5);
- 维度 0 的 stride 为 20:下一行首元素与当前行首元素间隔 20 个元素,即行号每 +1 需要跳过 20 个元素的位置,因此依次落在第 2、4、6、8 行,共 4 行。
该 Tensor 同样在内存排布上是不连续的:维度 1 上的 stride 为 2,该维度上间隔 1 个元素;维度 0 上的 stride 为 20,相邻元素间隔 20 个元素。它等价于对 10×10 矩阵做"隔行隔列"采样得到的 4×3 视图,两个维度均为非连续,比示例 1 的切片场景更具代表性。
通过示例 1 与示例 2 的对比可以看出:stride 越大,意味着该维度方向上的元素在内存中散布得越开;只要任一维度的 stride 不满足连续递推关系,整个 Tensor 即为非连续,而 offset 仅负责定位视图的起点,不参与连续性的判定。
五、如何创建非连续 aclTensor:aclCreateTensor 实战
创建 aclTensor 统一通过算子库公共接口aclCreateTensor完成(参见 docs/zh/context/data_structure.md 中关于 aclTensor 数据结构的说明,以及《算子库接口》"公共接口 > aclCreateTensor"章节)。从仓库中的算子示例代码 activation/celu_v2/examples/test_aclnn_celu.cpp 可以确认其典型调用形式:
*tensor = aclCreateTensor(shape.data(), // shape:各维度大小 shape.size(), // ndim:维度个数 dataType, // 数据类型,如 ACL_FLOAT strides.data(), // strides:各维度步长 0, // offset:首元素相对基地址的偏移 aclFormat::ACL_FORMAT_ND, // 数据格式 shape.data(), // originShape:原始 shape shape.size(), // originNdim:原始维度个数 deviceAddr); // device 侧基地址对于连续 Tensor,示例代码中给出了 strides 的标准递推算法——从最后一维开始,最后一维 stride 为 1,前一维 stride 等于后一维 shape 与 stride 的乘积(这正是行优先连续的充要条件):
// 计算连续 Tensor 的 strides std::vector<int64_t> strides(shape.size(), 1); for (int64_t i = shape.size() - 2; i >= 0; i--) { strides[i] = shape[i + 1] * strides[i + 1]; }要创建非连续的 aclTensor,只需把上述"由 shape 推导出的连续 strides"替换为视图实际对应的 strides,并填写非零的 offset。以示例 2 为例(示意写法,基地址沿用 10×10 数据块的 device 地址):
// 非连续视图:shape=(4,3),strides=(20,2),offset=22 std::vector<int64_t> shape = {4, 3}; std::vector<int64_t> strides = {20, 2}; int64_t offset = 22; aclTensor* view = aclCreateTensor(shape.data(), shape.size(), aclDataType::ACL_FLOAT, strides.data(), offset, aclFormat::ACL_FORMAT_ND, shape.data(), shape.size(), deviceAddr);注意aclCreateTensor的 offset 参数与示例代码中连续场景传0的区别:连续 Tensor 的首元素即基地址首元素,offset 为 0;非连续场景下必须显式传入视图首元素相对基地址的偏移量。
六、底层机制:算子内部如何消化非连续输入
非连续 Tensor 作为 API 层统一支持的输入形态,其底层处理在 ops-nn 仓库中可以找到对应的实现证据。
以 CELU 算子为例,其二阶段接口的实现 activation/celu_v2/op_api/aclnn_celu.cpp 中采用"先连续化、计算、再拷回"的固定套路:
// 将输入 self 转换成连续的 Tensor auto contiguousSelf = l0op::Contiguous(self, uniqueExecutor.get()); // 在连续 Tensor 上调用算子 kernel auto celuV2Out = l0op::CeluV2(contiguousSelf, alpha->ToFloat(), uniqueExecutor.get()); // 将计算结果拷贝到输出 out 上,out 可能是非连续的 tensor auto viewCopyResult = l0op::ViewCopy(castOut, out, uniqueExecutor.get());即算子内部先通过Contiguous把非连续输入规整为连续张量参与 kernel 计算,计算完成后通过ViewCopy将结果按输出 aclTensor 的 strides/offset 视图写回,从而对调用方屏蔽非连续排布的细节。该算子头文件 activation/celu_v2/op_api/aclnn_celu.h 与 README(activation/celu_v2/README.md)中均明确标注"支持非连续 Tensor"。
另一方面,公共 fallback 层 common/inc/op_graph/fallback_common_2stages_nn.h 展示了非连续视图的另一种典型用法:在对 MatMul 类算子做转置处理时,直接通过交换对应维度的 strides 构造一个非连续视图,实现零拷贝的"虚拟转置":
if (transpose) { auto swap = strides[dimN]; strides[dimN] = strides[dimM]; strides[dimM] = swap; // 交换 strides 即可得到转置视图,无需搬移数据 viewShape[dimN] = shape[dimM]; viewShape[dimM] = shape[dimN]; }从源码结构看,非连续 Tensor 机制正是通过"视图描述(strides/offset)+ 显式连续化(Contiguous)+ 结果写回(ViewCopy)"的组合,让上层 API 既能享受零拷贝描述视图的收益,又能让下层 kernel 始终基于规整数据计算,兼顾了灵活性与性能。
七、使用注意事项与限制
- 支持范围:目前大部分算子 API 的输入 aclTensor 支持非连续 Tensor,但并非全部。个别 API 是否支持、支持哪些维度的非连续,需以该 API 实际文档/README 的"非连续 Tensor"标注为准(例如 activation/celu_v2/docs/aclnnCelu&aclnnInplaceCelu.md 的参数表中逐项标注了输入输出的非连续支持情况)。
- 数据格式约束:非连续描述基于 ND(行优先)排布语义展开;非 ND 的私有格式(如 FRACTAL_NZ 等)当前绝大多数 aclnn API 尚不支持,具体以 API 描述为准,详见 docs/zh/context/data_format.md。
- 与空 Tensor 等特性的叠加:部分算子在支持非连续 Tensor 的同时还支持空 Tensor 与 0~8 维输入,使用时应一并核对 API 的完整约束。
- 更多基本概念:非连续 Tensor 属于算子库"基本概念"体系的一部分,与之配套的还有两段式接口、数据结构、数据类型、broadcast 关系、互推导关系等,可参阅 docs/zh/context/basic_concept.md 总览全部概念。
综上,(shape, strides, offset)三要素构成了 CANN ops-nn 描述非连续 Tensor 的完整模型:shape 决定逻辑形状,strides 决定各维度的内存间隔与连续性,offset 定位视图起点。理解并善用这一机制,可以在切片、转置、采样等场景下以零拷贝方式构造输入视图,同时借助算子内部 Contiguous + ViewCopy 的支撑,让非连续输入与普通连续输入一样直接参与 NPU 计算。
【免费下载链接】ops-nn本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。项目地址: https://gitcode.com/cann/ops-nn
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考