前言

刚把 Ascend C 算子调通,一跑性能测试,发现比预期慢了三倍。这种场景在昇腾 NPU 开发里太常见了。算子逻辑对了只是第一步,真正的挑战在于如何让代码在达芬奇架构上跑出该有的性能。

asc-tools 就是为了解决这个痛点而生的。它是一套完整的性能分析工具链,能帮你定位算子里的瓶颈在哪——是 DMA 搬运太慢?Vector 计算没喂饱?还是 tile 切分不合理导致频繁来回搬运数据?

这篇文章会从实际痛点出发,带你把 asc-tools 用起来,读懂它给出的每一份报告,并且根据报告结果调整你的算子实现。

安装 asc-tools 性能分析工具

asc-tools 不是独立安装的,它是 CANN 工具包的一部分。但很多人装了 CANN 之后发现 profiling 相关的命令用不了,通常是因为没装全组件。

完整安装 CANN 工具包

# 先检查当前 CANN 版本和已装组件
ls $ASCEND_HOME/toolkit/tools/asc-tools 2>/dev/null && echo "asc-tools已安装" || echo "需要安装"

# 如果没装,重新运行 CANN 安装脚本,选择自定义安装
# 确保勾选 Ascend-cann-toolkit 和 Ascend-cann-devtools 两个组件
sudo ./Ascend-cann-toolkit_8.0.RC1_linux-x86_64.run --install --install-for-all

# 安装完成后验证
asc-prof --version
# 预期输出类似:Ascend Profiling Tool 8.0.RC1

为什么要用 --install-for-all 这个参数把工具装到系统级路径,避免权限问题导致 profiling 数据采集失败。profiling 需要访问 NPU 的硬件计数器,没有 root 权限或者装到用户目录时经常采不到完整数据。

配置环境变量

# 把这几行加到 ~/.bashrc 或 ~/.zshrc
export ASCEND_HOME=/usr/local/Ascend
export PATH=$ASCEND_HOME/toolkit/tools/asc-tools:$PATH
export LD_LIBRARY_PATH=$ASCEND_HOME/toolkit/tools/asc-tools/lib64:$LD_LIBRARY_PATH

# 立刻生效
source ~/.bashrc  # 或 ~/.zshrc

为什么要配 LD_LIBRARY_PATH asc-tools 依赖一些动态库来做二进制注入和数据采集。不配这个变量,运行时会出现 error while loading shared libraries 的报错,而且这个错误在官方文档里提得不多,容易卡很久。

验证安装是否可用

# validate_asc_tools.py
# 为什么写这个验证脚本?很多人装完就直接跑真实算子,一旦出问题不知道是装错了还是算子写错了
# 用一个最简单的 vector add 来验证工具链完整性

import subprocess
import os

def check_asc_tools_installation():
    """检查 asc-tools 是否能正常工作"""
    
    # 检查核心二进制是否存在
    required_bins = ['asc-prof', 'asc-tile-analyzer', 'asc-dma-profiler']
    missing = []
    
    for bin_name in required_bins:
        result = subprocess.run(['which', bin_name], capture_output=True, text=True)
        if result.returncode != 0:
            missing.append(bin_name)
    
    if missing:
        print(f"[ERROR] 以下工具未找到: {missing}")
        print("[FIX] 重新安装 CANN toolkit,确保勾选 devtools 组件")
        return False
    
    # 检查 NPU 设备是否可达(profiling 需要访问硬件计数器)
    npu_check = subprocess.run(['npu-smi', 'info'], capture_output=True, text=True)
    if npu_check.returncode != 0:
        print("[WARN] NPU 设备不可访问,profiling 可能无法采集硬件数据")
        print("[FIX] 检查驱动是否安装,或是否有 /dev/davinci* 设备节点")
        return False
    
    print("[OK] asc-tools 安装正常,可以开始 profiling")
    return True

if __name__ == "__main__":
    check_asc_tools_installation()

跑一次完整的 Profiling

工具装好了,现在用一个实际的 Ascend C 算子来演示完整的 profiling 流程。假设你已经有一个写好的 vector_add_custom.cpp

编译时开启 Profiling 支持

# 关键:编译时必须加 --profiling 参数,否则跑出来的数据是空的
# 为什么默认不开启?因为 profiling 会注入额外的采集代码,影响运行时性能

asc-build --profiling vector_add_custom.cpp -o vector_add_profiled

# 如果用的是 CMake 构建体系
cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo \
      -DASCEND_PROFILING=ON \
      -DCMAKE_INSTALL_PREFIX=./install ..

为什么用 RelWithDebInfo 而不是 Release 纯 Release 模式会把调试符号 strip 掉,profiling 报告里只能看到地址看不到函数名和行号。RelWithDebInfo 保留了调试信息但依然开了优化,是 profiling 的最佳选择。

运行并采集数据

# 用 asc-prof 启动你的算子,它会自动注入采集逻辑
asc-prof --output ./profiling_results \
         --sample-interval 10ms \
         --events all \
         ./vector_add_profiled

# 参数解释:
#   --output          指定 profiling 数据输出目录
#   --sample-interval 采样间隔,10ms 是权衡精度和开销后的推荐值
#   --events all      采集所有硬件事件(DMA次数、Vector利用率、L2命中率等)

为什么采样间隔选 10ms? 太密集(比如 1ms)会导致采集开销过大,本身影响算子性能;太稀疏(比如 100ms)可能漏掉短周期的瓶颈。10ms 对于大多数算子来说是个安全的起点,如果发现报告里数据波动很大,可以降到 5ms。

生成可视化报告

# asc-tools 自带一个报告生成器,输出 HTML 方便在浏览器里看
asc-report --input ./profiling_results \
           --format html \
           --output ./profiling_report.html

# 也可以用 JSON 格式,方便后续用脚本做自动化分析
asc-report --input ./profiling_results \
           --format json \
           --output ./profiling_data.json

读懂性能报告里的关键指标

报告生成好了,打开 HTML 文件,你会看到一堆图表和数字。这一部分帮你理解哪些指标真正重要,哪些可以忽略。

1. DMA 搬运效率(Memory Throughput)

报告里会有一栏 DMA Transfer Efficiency,数值在 0%~100% 之间。如果你的算子这个值低于 60%,说明数据搬运成了瓶颈。

为什么 DMA 会成瓶颈? Ascend C 的编程模型里,你用 GlobalTensorLocalTensor 之间的数据搬运是显式的,需要调用 DataCopy 之类的接口。如果 tile 切得太小,每个 tile 处理完后都要等下一批数据搬进来,Vector Core 就空转了。

报告里怎么看? 找到 Pipeline View 这个图表,如果看到大段的 Waiting for DMA 空白区,就是这个问题。

2. Vector 利用率(Vector Utilization)

这个指标表示 Vector Core 的实际计算时间占整个算子运行时间的比例。理想情况下应该 >80%。

为什么达不到 80%? 常见原因有两个:一是上面说的 DMA 没跟上,Vector Core 饿死;二是你的算子里有很多标量操作(在 Host 端执行的代码),这些操作没法用 Vector Core 并行。

代码层面怎么改? 检查你的 Compute 函数里有没有不必要的 if-else 分支或者循环,这些通常会被编译器降级成标量执行。

// 不好的写法:有分支,编译器很难向量化
for (int i = 0; i < TILE_SIZE; i++) {
    if (srcLocal1.GetValue(i) > 0) {  // 这个分支会导致降级
        dstLocal.SetValue(i, srcLocal1.GetValue(i) + srcLocal2.GetValue(i));
    } else {
        dstLocal.SetValue(i, 0);
    }
}

// 好的写法:用 Select 原语,编译器能生成纯向量指令
auto mask = srcLocal1 > 0;  // 向量比较,返回 mask
Select(dstLocal, mask, srcLocal1 + srcLocal2, 0);  // 无分支选择

为什么 Select 更快? 因为它是 Ascend C 提供的原语,编译器知道怎么把它映射到达芬奇架构的向量指令上。而 if-else 分支在编译后通常变成标量比较加跳转,没法并行。

3. Tile 大小是否合理(Tile Size Analysis)

报告里有一个 Tile Size Recommendation 章节,会告诉你当前 tile 大小是否合适。

为什么 tile 大小这么重要? 达芬奇架构的 Local Memory(L1 Buffer)是有限的,一般是 1MB 左右。你的输入 tile、输出 tile、中间结果都得放进去。如果 tile 太大,放不下,编译器会自动帮你切分,但那种切分是保守的,性能通常不好。如果 tile 太小,DMA 搬运次数变多, overhead 上升。

怎么根据报告调整? 假设报告建议你的 tile 大小从当前的 16KB 调到 32KB:

// 修改前
constexpr int TILE_SIZE = 16 * 1024;  // 16KB per tile

// 修改后
constexpr int TILE_SIZE = 32 * 1024;  // 32KB per tile,根据 profiling 报告调整

// 但注意:调大 tile 之后要检查 Local Memory 是否够用
// 计算公式:input_tile * 2 (双缓冲) + output_tile + workspace <= L1_BUFFER_SIZE
static_assert(TILE_SIZE * 2 + TILE_SIZE + TILE_SIZE / 2 <= 1024 * 1024, 
              "Tile size too large for L1 buffer");

为什么用 static_assert 而不是运行时 check? 因为 tile 大小是编译期常量(必须是,否则没法做内存分配),在编译期报错比跑起来 segfault 好得多。

根据报告调整 Tile 大小:一个完整案例

光看指标不够,这一节用一个完整的例子走一遍「跑 profiling → 读报告 → 改代码 → 验证性能提升」的闭环。

初始版本的性能问题

假设你的算子是做一个向量加法 + ReLU 激活。初始 tile 大小设的是 8KB,跑出来的 profiling 报告是这样的:

=== Profiling Result: vector_add_relu ===
Total Time:           1450 μs
DMA Transfer Time:    980 μs  (67.6%)  <-- 太高了!
Vector Compute Time:  320 μs  (22.1%)
Vector Utilization:   35.2%            <-- 太低了!
Recommended Tile Size: 32KB (current: 8KB)

为什么 DMA 时间占比这么高? Tile 太小意味着要处理同样总量的数据,需要更多的 DMA 次数。每次 DMA 启动都有固定开销(大概几百个 cycle),这些 overhead 累加起来就很可观了。

调整 Tile 大小

// vector_add_relu_custom.cpp
// 为什么从 8KB 调到 32KB?因为 profiling 报告明确说了 recommended tile size = 32KB
// 但调之前得算一下 L1 Buffer 够不够

#include "kernel_operator.h"

constexpr int ORIGINAL_TILE_SIZE = 8 * 1024;   // 原始:8KB
constexpr int OPTIMIZED_TILE_SIZE = 32 * 1024; // 优化后:32KB

// 内存布局计算(为什么这么算?)
// input1: OPTIMIZED_TILE_SIZE * sizeof(half) = 64KB
// input2: OPTIMIZED_TILE_SIZE * sizeof(half) = 64KB
// output: OPTIMIZED_TILE_SIZE * sizeof(half) = 64KB
// workspace (double buffer): OPTIMIZED_TILE_SIZE * sizeof(half) * 2 = 128KB
// Total: 320KB < 1MB (L1 Buffer Size),安全

class VectorAddReluKernel {
public:
    __aicore__ static void Compute(KernelContext& ctx) {
        // 用 Tiling 结构体从 Host 侧传入实际 tile 大小
        // 为什么不直接写死 32KB?因为不同输入形状可能需要不同 tile 大小
        // Tiling 结构体能让你在运行时动态调整
        auto tiling = ctx.GetTiling();
        int tileSize = tiling.get<int>("tileSize");  // 从 Host 侧传来的 tile 大小
        
        // ... 后续的 DMA 和 Vector 计算代码 ...
    }
};

为什么用 Tiling 结构体而不是直接写死 OPTIMIZED_TILE_SIZE 因为不同形状的输入,最优 tile 大小可能不一样。小输入用大 tile 反而浪费内存。通过 Tiling 结构体,你可以在 Host 侧根据输入形状算出一个合适的 tile 大小,再传给 Device 侧。

Host 侧 Tiling 计算

# tiling_calculator.py
# 为什么用 Python 算 Tiling?因为 Host 侧通常用 Python(通过 ACL API)或者 C++ 来算
# 这里用 Python 演示更清晰

def calculate_optimal_tiling(input_elements, dtype_size=2):
    """
    根据输入大小和硬件限制计算最优 tile 大小
    
    Args:
        input_elements: 输入张量的元素总数
        dtype_size: 每个元素占的字节数(half=2, float=4)
    
    Returns:
        optimal_tile_size: 最优的 tile 元素数
        actual_memory_usage: 实际内存占用量(字节)
    """
    
    L1_BUFFER_SIZE = 1024 * 1024  # 1MB,达芬奇架构的 L1 Buffer 大小
    
    # 每个 tile 需要的内存:
    #   input1:  tile_size * dtype_size
    #   input2:  tile_size * dtype_size
    #   output:  tile_size * dtype_size
    #   workspace (double buffer for pipeline): tile_size * dtype_size * 2
    # 总共:5 * tile_size * dtype_size
    
    max_tile_bytes = L1_BUFFER_SIZE // 5  # 安全上限:205KB
    
    # 取 32KB 的整数倍(为什么?因为 DMA 传输对齐要求)
    tile_size_bytes = min(32 * 1024, max_tile_bytes)
    optimal_tile_size = tile_size_bytes // dtype_size
    
    actual_memory_usage = 5 * optimal_tile_size * dtype_size
    
    print(f"[Tiling] Input elements: {input_elements}")
    print(f"[Tiling] Optimal tile size: {optimal_tile_size} elements ({tile_size_bytes} bytes)")
    print(f"[Tiling] Actual memory usage: {actual_memory_usage / 1024:.1f} KB / 1024 KB")
    
    return optimal_tile_size, actual_memory_usage

# 测试不同输入大小
for nelem in [1024, 8192, 65536, 524288]:
    tile, mem = calculate_optimal_tiling(nelem)
    num_tiles = (nelem + tile - 1) // tile
    print(f"  {nelem} elements → {num_tiles} tiles of size {tile}")
    print()

为什么 DMA 传输要求 32KB 对齐? 达芬奇架构的 DMA 引擎(EDMA)在传输数据时,如果源地址和目的地址都是 32KB 对齐的,可以用连续传输模式,效率最高。不对齐的话,EDMA 会自动切成多次传输,每次都有额外的 setup 开销。

调整后的性能对比

改完 tile 大小之后,重新跑 profiling:

=== Profiling Result (After Optimization): vector_add_relu ===
Total Time:           520 μs  (原来 1450 μs,提升 2.8x)
DMA Transfer Time:    180 μs  (34.6%)  <-- 大幅下降
Vector Compute Time:  290 μs  (55.8%)
Vector Utilization:   78.3%            <-- 大幅提升!

为什么总耗时不是等比例下降的? 因为 DMA 时间和计算时间有部分重叠(pipeline)。调大 tile 之后,DMA 次数减少,每次 DMA 传输的数据量增加,但 DMA 和计算的重叠度也变了。理想的情况是 DMA 刚好在计算期间把下一批数据搬完,这样 DMA 时间就被完全隐藏了。

进阶:用 asc-tools 做逐行性能分析

上面的方法是看整体指标,但有时候你需要更精细的信息——比如到底是哪一行代码拖慢了整个算子。

用 asc-annotate 插入性能标记

// vector_add_relu_annotated.cpp
// 为什么要在代码里插标记?因为默认 profiling 只能看到函数级别的性能
// 插入自定义标记后,可以在报告里看到每个代码段的耗时

#include "kernel_operator.h"
#include "asc_profiling_annotate.h"  // asc-tools 提供的标注 API

class VectorAddReluKernel {
public:
    __aicore__ static void Compute(KernelContext& ctx) {
        ASC_PROF_START(compute_kernel);  // 标记开始
        
        // === 阶段1:DMA 搬运输入 ===
        ASC_PROF_START(dma_input);
        DataCopy(inLocal1, inGlobal1[offset], tileSize * sizeof(half));
        DataCopy(inLocal2, inGlobal2[offset], tileSize * sizeof(half));
        ASC_PROF_END(dma_input);  // 标记结束
        
        // === 阶段2:Vector 计算 ===
        ASC_PROF_START(vector_compute);
        Add(tmpLocal, inLocal1, inLocal2, tileSize);
        Relu(dstLocal, tmpLocal, tileSize);
        ASC_PROF_END(vector_compute);
        
        // === 阶段3:DMA 搬回结果 ===
        ASC_PROF_START(dma_output);
        DataCopy(outGlobal[offset], dstLocal, tileSize * sizeof(half));
        ASC_PROF_END(dma_output);
        
        ASC_PROF_END(compute_kernel);
    }
};

为什么用 ASC_PROF_START/END 而不是自己计时? 自己用时钟周期计数器计时的方法(比如读 clock64())在 NPU 上不太准,因为编译器的指令重排会把你的计时代码和实际代码打乱。ASC_PROF_START/END 是 asc-tools 提供的原语,编译器会保证它们不被重排,而且采集的数据会和硬件计数器关联。

查看逐行报告

# 编译带标注的版本
asc-build --profiling --annotate vector_add_relu_annotated.cpp -o vector_add_annotated

# 运行并采集
asc-prof --output ./profiling_annotated \
          --events all \
          --annotate \
          ./vector_add_annotated

# 生成报告(会包含自定义的标注信息)
asc-report --input ./profiling_annotated \
           --format html \
           --annotate \
           --output ./profiling_annotated_report.html

打开 HTML 报告后,找到 Annotated Timeline 这个视图。你会看到 dma_inputvector_computedma_output 三个阶段的耗时柱状图。如果 dma_input 占比很高,说明数据搬运是瓶颈;如果 vector_compute 占比高但 Vector Utilization 低,说明计算代码本身有问题(比如用了太多标量操作)。

总结

asc-tools 不是什么黑魔法,它只是帮你把算子运行时的硬件行为记录下来,用可视化的方式呈现出来。真正解决问题的还是你对达芬奇架构的理解——知道 DMA 什么时候会阻塞 Vector Core,知道 tile 大小怎么影响 L1 Buffer 的利用率,知道哪些 Ascend C 原语能生成高效的向量指令。


仓库链接:https://atomgit.com/cann/asc-tools

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐