8.1 算子调用的几种方式

在这里插入图片描述
嗲用的方法得适当,不然就容易报错哦,用对了才能编译成功。
在这里插入图片描述

8.1.1 FrameworkLaunch方式

FrameworkLaunch是通过框架调用自定义算子的方式。算子编译部署后,会自动生成aclnn API,可以直接在应用程序中调用。这是最常用的调用方式,适合集成到实际应用中。

8.1.2 KernelLaunch方式

KernelLaunch是直接调用核函数的方式,更底层,适合快速验证和测试。不需要完整的算子工程,只需要核函数代码。

8.1.3 第三方框架调用

自定义算子也可以集成到PyTorch、TensorFlow等深度学习框架中,通过框架的接口调用。


8.2 aclnn API调用详解

8.2.1 什么是aclnn API

aclnn(Ascend Computing Language Neural Network)是昇腾提供的神经网络计算API。自定义算子编译部署后,会自动生成对应的aclnn API,比如AddCustom算子会生成aclnnAddCustom API。

8.2.2 两段式接口

aclnn API采用两段式接口设计:

第一段接口:获取workspace大小,准备执行器。

aclnnStatus aclnnAddCustomGetWorkspaceSize(
    const aclTensor *x,           // 输入tensor x
    const aclTensor *y,           // 输入tensor y
    const aclTensor *out,          // 输出tensor
    uint64_t *workspaceSize,       // 返回workspace大小
    aclOpExecutor **executor        // 返回执行器
);

这个接口会计算算子执行需要多少workspace内存,并创建执行器。workspace是算子执行时需要的临时内存,有些算子需要,有些不需要。

第二段接口:执行算子。

aclnnStatus aclnnAddCustom(
    void *workspace,               // workspace内存
    int64_t workspaceSize,         // workspace大小
    aclOpExecutor *executor,        // 执行器
    aclrtStream stream             // 执行流
);

这个接口实际执行算子计算。需要传入workspace内存、执行器和流。

8.2.3 为什么是两段式

两段式设计的好处:

提前知道内存需求:第一段接口告诉你需要多少workspace,你可以提前分配,避免执行时内存不足。

执行器复用:执行器可以复用,如果输入输出的形状不变,可以只调用一次第一段接口,多次调用第二段接口,提高效率。

异步执行:第二段接口是异步的,调用后立即返回,不会阻塞。需要同步的话调用aclrtSynchronizeStream。


8.3 AclNNInvocation示例详解

8.3.1 项目结构

基于samples中的AclNNInvocation示例,项目结构如下:

AclNNInvocation/
├── inc/                    // 头文件
│   ├── common.h           // 公共函数声明
│   ├── op_runner.h        // 算子运行器声明
│   └── operator_desc.h    // 算子描述声明
├── src/                    // 源文件
│   ├── main.cpp          // 主程序
│   ├── op_runner.cpp     // 算子运行器实现
│   ├── operator_desc.cpp // 算子描述实现
│   └── common.cpp        // 公共函数实现
├── scripts/               // 脚本
│   ├── gen_data.py       // 生成测试数据
│   └── verify_result.py  // 验证结果
├── input/                 // 输入数据目录
├── output/                // 输出数据目录
└── run.sh                 // 运行脚本

8.3.2 算子描述(OperatorDesc)

算子描述定义了算子的输入输出信息:

// operator_desc.h
struct OperatorDesc {
    OperatorDesc();
    ~OperatorDesc();
    
    // 添加输入tensor描述
    OperatorDesc &AddInputTensorDesc(
        aclDataType dataType,    // 数据类型,如ACL_FLOAT16
        int numDims,            // 维度数
        const int64_t *dims,    // 形状数组
        aclFormat format        // 数据格式,如ACL_FORMAT_ND
    );
    
    // 添加输出tensor描述
    OperatorDesc &AddOutputTensorDesc(
        aclDataType dataType,
        int numDims,
        const int64_t *dims,
        aclFormat format
    );
    
    std::vector<aclTensorDesc *> inputDesc;   // 输入描述列表
    std::vector<aclTensorDesc *> outputDesc;  // 输出描述列表
};

使用示例:

OperatorDesc CreateOpDesc()
{
    // 定义算子:两个输入,一个输出,都是[8, 2048]的float16
    std::vector<int64_t> shape{8, 2048};
    aclDataType dataType = ACL_FLOAT16;
    aclFormat format = ACL_FORMAT_ND;
    
    OperatorDesc opDesc;
    opDesc.AddInputTensorDesc(dataType, shape.size(), shape.data(), format);
    opDesc.AddInputTensorDesc(dataType, shape.size(), shape.data(), format);
    opDesc.AddOutputTensorDesc(dataType, shape.size(), shape.data(), format);
    return opDesc;
}

8.3.3 算子运行器(OpRunner)

OpRunner封装了算子调用的完整流程,包括内存管理、数据拷贝、算子执行等。

初始化

bool OpRunner::Init()
{
    // 为每个输入分配内存
    for (size_t i = 0; i < numInputs_; ++i) {
        auto size = GetInputSize(i);
        
        // 分配Device内存
        void *devMem = nullptr;
        aclrtMalloc(&devMem, size, ACL_MEM_MALLOC_HUGE_FIRST);
        devInputs_.emplace_back(devMem);
        
        // 创建DataBuffer
        inputBuffers_.emplace_back(aclCreateDataBuffer(devMem, size));
        
        // 分配Host内存(用于数据拷贝)
        void *hostInput = nullptr;
        if (g_isDevice) {
            aclrtMalloc(&hostInput, size, ACL_MEM_MALLOC_HUGE_FIRST);
        } else {
            aclrtMallocHost(&hostInput, size);
        }
        hostInputs_.emplace_back(hostInput);
        
        // 创建Tensor
        aclTensor *inputTensor = aclCreateTensor(
            GetInputShape(i).data(), 
            GetInputNumDims(i), 
            GetInputDataType(i),
            nullptr, 0,
            GetInputFormat(i),
            GetInputShape(i).data(),
            GetInputNumDims(i),
            devInputs_[i]
        );
        inputTensor_.emplace_back(inputTensor);
    }
    
    // 为每个输出分配内存(类似输入)
    // ...
    
    return true;
}

执行算子

bool OpRunner::RunOp()
{
    // 1. 拷贝输入数据到Device
    for (size_t i = 0; i < numInputs_; ++i) {
        auto size = GetInputSize(i);
        aclrtMemcpyKind kind = g_isDevice ? 
            ACL_MEMCPY_DEVICE_TO_DEVICE : 
            ACL_MEMCPY_HOST_TO_DEVICE;
        aclrtMemcpy(devInputs_[i], size, hostInputs_[i], size, kind);
    }
    
    // 2. 创建执行流
    aclrtStream stream = nullptr;
    aclrtCreateStream(&stream);
    
    // 3. 第一段接口:获取workspace大小
    size_t workspaceSize = 0;
    aclOpExecutor *handle = nullptr;
    auto ret = aclnnAddCustomGetWorkspaceSize(
        inputTensor_[0], 
        inputTensor_[1], 
        outputTensor_[0], 
        &workspaceSize, 
        &handle
    );
    if (ret != ACL_SUCCESS) {
        ERROR_LOG("Get Workspace failed");
        return false;
    }
    
    // 4. 分配workspace内存(如果需要)
    void *workspace_ = nullptr;
    if (workspaceSize != 0) {
        aclrtMalloc(&workspace_, workspaceSize, ACL_MEM_MALLOC_HUGE_FIRST);
    }
    
    // 5. 第二段接口:执行算子
    ret = aclnnAddCustom(workspace_, workspaceSize, handle, stream);
    if (ret != ACL_SUCCESS) {
        ERROR_LOG("Execute Operator failed");
        return false;
    }
    
    // 6. 同步等待执行完成
    aclrtSynchronizeStreamWithTimeout(stream, 5000);
    
    // 7. 拷贝输出数据到Host
    for (size_t i = 0; i < numOutputs_; ++i) {
        auto size = GetOutputSize(i);
        aclrtMemcpyKind kind = g_isDevice ? 
            ACL_MEMCPY_DEVICE_TO_DEVICE : 
            ACL_MEMCPY_DEVICE_TO_HOST;
        aclrtMemcpy(hostOutputs_[i], size, devOutputs_[i], size, kind);
    }
    
    aclrtDestroyStream(stream);
    return true;
}

8.3.4 主程序流程

main.cpp展示了完整的调用流程:

int main(int argc, char **argv)
{
    // 1. 初始化资源
    if (!InitResource()) {
        ERROR_LOG("Init resource failed");
        return FAILED;
    }
    
    // 2. 运行算子
    if (!RunOp()) {
        DestroyResource();
        return FAILED;
    }
    
    // 3. 清理资源
    DestroyResource();
    return SUCCESS;
}

bool RunOp()
{
    // 创建算子描述
    OperatorDesc opDesc = CreateOpDesc();
    
    // 创建算子运行器
    OpRunner opRunner(&opDesc);
    if (!opRunner.Init()) {
        return false;
    }
    
    // 加载输入数据
    if (!SetInputData(opRunner)) {
        return false;
    }
    
    // 执行算子
    if (!opRunner.RunOp()) {
        return false;
    }
    
    // 处理输出数据
    if (!ProcessOutputData(opRunner)) {
        return false;
    }
    
    return true;
}

8.3.5 数据生成和验证

生成测试数据

# gen_data.py
import numpy as np

def gen_golden_data_simple():
    # 生成随机输入数据
    input_x = np.random.uniform(1, 100, [8, 2048]).astype(np.float16)
    input_y = np.random.uniform(1, 100, [8, 2048]).astype(np.float16)
    
    # 计算真值(参考结果)
    golden = (input_x + input_y).astype(np.float16)
    
    # 保存为二进制文件
    input_x.tofile("./input/input_x.bin")
    input_y.tofile("./input/input_y.bin")
    golden.tofile("./output/golden.bin")

验证结果

# verify_result.py
# 读取输出结果和真值,对比验证

8.3.6 运行脚本

run.sh脚本自动化了整个流程:

#!/bin/bash

# 1. 清除遗留文件
rm -rf ./input/*.bin
rm -rf ./output/*.bin

# 2. 生成测试数据
python3 scripts/gen_data.py

# 3. 编译可执行文件
mkdir -p build
cd build
cmake ../src
make

# 4. 运行算子
cd ../output
./execute_add_op

# 5. 验证结果
cd ..
python3 scripts/verify_result.py output/output_z.bin output/golden.bin

8.4 内存管理详解

8.4.1 Host内存和Device内存

在昇腾系统中,有两类内存:

Host内存:CPU侧的内存,应用程序可以直接访问。

Device内存:NPU侧的内存,需要通过DMA传输访问。

数据要在Host和Device之间传输,需要分别分配Host内存和Device内存。

8.4.2 内存分配

Device内存分配

void *devMem = nullptr;
aclrtMalloc(&devMem, size, ACL_MEM_MALLOC_HUGE_FIRST);

ACL_MEM_MALLOC_HUGE_FIRST表示优先分配大页内存,性能更好。

Host内存分配

void *hostMem = nullptr;
// 如果运行在Device侧
if (g_isDevice) {
    aclrtMalloc(&hostMem, size, ACL_MEM_MALLOC_HUGE_FIRST);
} else {
    // 如果运行在Host侧
    aclrtMallocHost(&hostMem, size);
}

8.4.3 数据拷贝

数据在Host和Device之间拷贝:

// Host到Device
aclrtMemcpy(devMem, size, hostMem, size, ACL_MEMCPY_HOST_TO_DEVICE);

// Device到Host
aclrtMemcpy(hostMem, size, devMem, size, ACL_MEMCPY_DEVICE_TO_HOST);

// Device到Device(如果运行在Device侧)
aclrtMemcpy(devMem2, size, devMem1, size, ACL_MEMCPY_DEVICE_TO_DEVICE);

8.4.4 Tensor和DataBuffer

aclTensor:描述tensor的元数据,包括形状、数据类型、格式等。

aclDataBuffer:描述实际的数据缓冲区,指向Device内存。

两者配合使用,Tensor描述数据,DataBuffer指向实际内存。


8.5 执行流程详解

8.5.1 完整执行流程

调用自定义算子的完整流程:

  1. 初始化ACL:aclInit(),初始化ACL环境。

  2. 设置设备:aclrtSetDevice(),选择要使用的NPU设备。

  3. 创建算子描述:定义输入输出的形状、数据类型等。

  4. 分配内存:为输入输出分配Host和Device内存。

  5. 创建Tensor:根据算子描述创建aclTensor。

  6. 加载输入数据:从文件读取数据,拷贝到Device内存。

  7. 获取workspace:调用第一段接口,获取workspace大小。

  8. 分配workspace:如果需要,分配workspace内存。

  9. 执行算子:调用第二段接口,执行算子计算。

  10. 同步等待:等待算子执行完成。

  11. 拷贝输出:从Device内存拷贝结果到Host内存。

  12. 保存结果:将结果保存到文件。

  13. 清理资源:释放内存,销毁资源。

8.5.2 异步执行

aclnn API是异步的,调用后立即返回,不会阻塞。如果需要等待执行完成,要调用同步接口:

// 同步等待,超时时间5000ms
aclrtSynchronizeStreamWithTimeout(stream, 5000);

异步执行的好处是可以让多个算子并发执行,提高效率。

8.5.3 Stream的使用

Stream是执行流,用于管理异步执行。可以创建多个Stream,让不同的算子在不同的Stream上并发执行。

// 创建Stream
aclrtStream stream = nullptr;
aclrtCreateStream(&stream);

// 在Stream上执行算子
aclnnAddCustom(workspace_, workspaceSize, handle, stream);

// 同步Stream
aclrtSynchronizeStream(stream);

// 销毁Stream
aclrtDestroyStream(stream);

8.6 错误处理

8.6.1 返回值检查

所有的ACL API都有返回值,要检查返回值:

auto ret = aclrtMalloc(&devMem, size, ACL_MEM_MALLOC_HUGE_FIRST);
if (ret != ACL_SUCCESS) {
    ERROR_LOG("Malloc failed, error code: %d", ret);
    return false;
}

8.6.2 常见错误

内存分配失败:可能是内存不足,或者参数错误。

Tensor创建失败:可能是形状、数据类型等参数不匹配。

算子执行失败:可能是输入数据有问题,或者算子实现有bug。

同步超时:可能是算子执行时间太长,或者有死锁。

8.6.3 调试技巧

打印日志:在关键位置打印日志,追踪执行流程。

检查数据:打印输入输出数据,验证数据是否正确。

检查内存:检查内存是否分配成功,是否越界。

检查返回值:所有API调用都要检查返回值。


8.7 性能优化建议

8.7.1 内存分配优化

大页内存:使用ACL_MEM_MALLOC_HUGE_FIRST分配大页内存,性能更好。

内存复用:如果可能,复用已分配的内存,减少分配释放开销。

批量分配:一次分配多点内存,减少分配次数。

8.7.2 数据拷贝优化

减少拷贝:尽量减少Host和Device之间的数据拷贝。

异步拷贝:如果支持,用异步拷贝,让拷贝和计算重叠。

批量拷贝:一次拷贝多点数据,减少拷贝次数。

8.7.3 执行优化

Stream并发:用多个Stream让多个算子并发执行。

执行器复用:如果输入输出形状不变,复用执行器。

流水线:让数据加载、计算、结果写回流水线化。


学习检查点

学完这一篇,你应该能做到这些:

理解aclnn API的两段式接口设计,知道为什么这样设计。掌握算子描述的创建,知道如何定义输入输出。理解OpRunner的实现,知道如何管理内存、拷贝数据、执行算子。掌握完整的调用流程,从初始化到执行到清理。能够参考AclNNInvocation示例,实现自己的算子调用程序。

实践练习

运行AclNNInvocation示例:在ModelArts Notebook中运行1_add_frameworklaunch/AclNNInvocation示例,理解完整的调用流程。

修改示例:尝试修改算子描述,改成不同的形状或数据类型,看看效果。

实现新的调用程序:参考AclNNInvocation,实现一个调用其他算子(比如Mul、Sub)的程序。

性能测试:测试不同大小的输入,看看性能如何变化。尝试优化内存分配和数据拷贝,提升性能。


下一步:掌握了算子调用方式后,你已经能够完整地开发和使用自定义算子了。可以继续学习其他调用方式,比如KernelLaunch、第三方框架集成等,或者在实际项目中应用这些知识。

2025年昇腾CANN训练营第二季,基于CANN开源开放全场景,推出0基础入门系列、码力全开特辑、开发者案例等专题课程,助力不同阶段开发者快速提升算子开发技能。获得Ascend C算子中级认证,即可领取精美证书,完成社区任务更有机会赢取华为手机,平板、开发板等大奖。

报名链接:https://www.hiascend.com/developer/activities/cann20252

社区地址:https://www.hiascend.com/developer

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐