2026 年 4 月 24 日,DeepSeek V4 发布。据《华为算力“开链”》报道,这一代模型把上下文长度从 128K 扩展到 1M。序列拉长后,推理时间不只花在矩阵乘法上:中间张量有多大、要落回 Global Memory 几次、kernel 下发是否过碎,都会影响首字延迟。
这也是昇腾 910C 调优最容易走偏的地方。看到耗时高,便把问题归咎于 CANN 通用库,然后直接重写算子,风险很大。通用算子需要覆盖多种 Shape、数据类型和运行条件;Ascend C 自定义算子更适合边界明确、调用频繁,而且已经被 profile 确认为热点的路径。
需要先说清楚:自定义算子不是绕过 CANN。华为公开资料将 CANN 定位为昇腾 AI 的基础软件栈,昇腾官方算子开发页面也明确把算子开发定义为针对神经网络基础单元的设计与实现。自定义代码仍要在 CANN 提供的工具和运行环境中编译、调度。
先看 profile,别急着写 kernel
鲲鹏昇腾开发者社区对 profiling 功能的说明中,列出了算子运行耗时、通信时长、内存占用和下发延迟等信息。写 Ascend C 之前,至少要回答几件事:
- 耗时集中在单个 MatMul,还是散落在 Scale、Mask、Softmax 等一串小算子里;
- 中间结果是否反复写回 HBM,再被下一个算子读出;
- 动态 Shape 的尾块是否带来了额外 padding、分支或数据搬运;
- 问题主要出现在 prefill 和 TTFT,还是 decode 与高并发阶段。
如果 LLaMA-3-70B 的 128K 输入只是出现了 TTFT 波动,却没有对应的算子时间线和内存轨迹,就不能直接认定标准算子库是原因。反过来,如果 profile 显示一串轻量向量算子被频繁下发,中间张量不断物化,自定义融合才有清楚的优化目标。
单个大矩阵计算已经占据主要耗时的情况下,重写一个 Vector 算子通常不会改变全局表现。先找瓶颈,比先写代码重要。
真正要减少的是中间结果落回 HBM
PyTorch 计算图进入 NPU 后,CANN 会根据图结构、Shape 和版本进行编译与下发。Softmax、Scale、MatMul 并非在任何情况下都会拆成彼此孤立的 kernel,也不能笼统地说每个算子都会把结果写回 HBM。是否发生融合、缓存复用或额外搬运,应以实际 profile 为准。
确认存在冗余搬运后,Ascend C 融合通常围绕三个目标展开:
- 减少 kernel 下发次数;
- 让中间数据尽量留在 Local Memory;
- 按真实 Shape 设计 Tiling,而不是沿用覆盖面更广的通用切块。
融合也不是越大越好。把 Scale、Mask、Softmax 等连续向量操作放在一起,通常比直接包住整段 Attention 更容易验证。矩阵和向量阶段分别使用 Cube、Vector 等计算资源,代码写进同一个算子,并不代表同步和数据交换会自动消失。融合范围过大,还会挤占片上缓存,增加动态 Shape 和精度验证的工作量。
32 字节对齐:搬运长度和有效长度要分开
在 Ascend C 中,不少 DataCopy 路径按 32 字节数据块组织,具体限制仍要核对所用接口和 CANN 版本。FP16 的单个元素占 2 字节,因此 32 字节对应 16 个元素。Tiling 时常见的做法,是把每个 AI Core 的任务量向上取整到 16 的倍数。
uint32_t totalLength = 131072; // 128K
uint32_t elemSize = sizeof(half); // FP16 为 2 字节
uint32_t alignElems = 32 / elemSize; // 16 个元素
uint32_t taskPerCore =
(totalLength + coreNum - 1) / coreNum;
uint32_t alignedTaskPerCore =
(taskPerCore + alignElems - 1) / alignElems * alignElems;
这里的 alignedTaskPerCore 只是分配和搬运时使用的上限,不能直接当作有效数据长度。最后一个 AI Core 仍要根据逻辑边界计算 validLength,尾块计算需要 mask,输出也不能写出真实 Tensor 范围。
否则,padding 数据可能参与 Softmax 或 Reduce,结果会先于性能出现问题。真正的越界还可能触发 Hardware Exception。但不能反过来把所有非对齐访问都说成必然报错,实际行为取决于搬运接口、数据类型和执行路径。
动态 Shape 下,Host 端最好同时下发逻辑长度和对齐后的搬运长度。为 128K 设计的 Tile,不一定适合 4K 或 8K;如果请求长度跨度很大,可以按长度区间准备不同的 Tiling,而不是让一套切块覆盖全部输入。
双缓冲真正重叠的是三段流水
双缓冲会在 Local Memory 中准备两组缓冲区。当一组数据进入 Compute 阶段时,另一组可以执行 CopyIn 或 CopyOut。TQue<..., 2> 只是建立双缓冲的条件,能否真正隐藏搬运时间,还要看 CopyIn、Compute、CopyOut 是否形成流水,以及三段耗时是否匹配。
下面是简化后的 Ascend C 结构:
constexpr int32_t BUFFER_NUM = 2;
TQue<QuePosition::VECIN, BUFFER_NUM> inQueue;
TQue<QuePosition::VECOUT, BUFFER_NUM> outQueue;
void CopyIn(int32_t i) {
LocalTensor<half> x = inQueue.AllocTensor<half>();
DataCopy(x, gIn[i * tileSize], tileSize);
inQueue.EnQue(x);
}
void Compute() {
LocalTensor<half> x = inQueue.DeQue<half>();
LocalTensor<half> y = outQueue.AllocTensor<half>();
VectorCompute(y, x);
outQueue.EnQue(y);
inQueue.FreeTensor(x);
}
void CopyOut(int32_t i) {
LocalTensor<half> y = outQueue.DeQue<half>();
DataCopy(gOut[i * tileSize], y, tileSize);
outQueue.FreeTensor(y);
}
void Process(int32_t tileNum) {
for (int32_t i = 0; i < tileNum; ++i) {
CopyIn(i);
Compute();
CopyOut(i);
}
}
这段代码只展示队列关系,省略了 TPipe::InitBuffer、尾块处理和事件同步,不能直接用于生产环境。接口名称和参数也要按实际使用的 CANN 8.0 小版本核对。
Tile 太大时,片上空间可能放不下两组输入和输出缓冲;Tile 太小,又会增加循环和同步开销。队列深度改成 2 之后,仍要回到 profiling 时间线,确认数据搬运与计算是否真的发生了重叠。
固定增幅,没有测试口径就站不住
算子耗时下降,不等于 TTFT 和端到端吞吐会按同样幅度变化。编译图、调度器、KV Cache、并发策略和通信都可能成为新的限制项。发布昇腾 910C 性能数据时,至少应写清下面这些条件:
- 具体硬件、驱动、固件和 CANN 8.0 小版本;
- 模型、精度、输入长度、输出长度、batch 和并发数;
- 静态或动态 Shape,以及被替换的算子边界;
- 预热方式、测试次数和统计口径;
- 算子耗时、HBM 读写、下发延迟、显存峰值、TTFT 与端到端吞吐。
现有公开素材没有提供一套可复现的 910C 测试配置,因此不适合把固定增幅写成普遍结论。更可靠的做法,是在同一环境中保存改造前后的 profile,并同时检查算子级和端到端结果。算子变快但 TTFT 没有变化,说明它很可能不是系统瓶颈。
在昇腾 910C 上,Ascend C 最适合处理边界稳定、热点明确的算子链。profile 显示搬运和下发占主导,就尝试融合、对齐和双缓冲;如果主要时间仍花在大矩阵计算上,就不要为了自定义而自定义。通用库更容易升级,自定义算子换来的则是更窄的适配范围和更高的维护成本。
调优目标不是绕开 CANN,而是让 CANN 中的数据路径更贴近实际负载。