昇腾训练营报名链接:https://www.hiascend.com/developer/activities/cann20252#cann-camp-2502-intro

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


前言

写完第一个算子后,我发现自己对数据类型和内存管理还是一知半解。什么时候用FP16,什么时候用FP32?Global Memory和Unified Buffer到底有什么区别?内存对齐是怎么回事?

这些问题直到我踩了几次坑,才慢慢理解。让我们先看看CANN的整体架构:

CANN架构

从官方架构图可以看到,CANN的异构计算架构包含多层内存层次和不同的数据类型支持。今天就来系统梳理CANN的数据类型和内存管理,这是写高性能算子的基础!

一、CANN支持的数据类型

1.1 标量类型

CANN支持多种数据类型,每种都有各自的用途:

// 浮点类型
half      // FP16,16位浮点(最常用)
float     // FP32,32位浮点
bfloat16  // BF16,Google提出的16位浮点(部分硬件支持)

// 整数类型
int8_t    // 8位有符号整数
uint8_t   // 8位无符号整数
int16_t   // 16位有符号整数
uint16_t  // 16位无符号整数
int32_t   // 32位有符号整数
uint32_t  // 32位无符号整数
int64_t   // 64位有符号整数(部分操作支持)

// 布尔类型
bool      // 1字节

1.2 数据类型详解

FP16 (half)

half x = 1.5f;  // FP16
// 内存占用:2字节
// 精度:约3位小数
// 范围:±6.5e4
// 用途:深度学习最常用,速度快,精度够用

FP16的内存布局:

15    10 9           0
[符号][指数][尾数部分]
1位  5位   10位

FP32 (float)

float y = 1.5f;  // FP32
// 内存占用:4字节
// 精度:约7位小数
// 范围:±3.4e38
// 用途:高精度要求的场景

BF16 (bfloat16)

bfloat16 z = 1.5f;  // BF16
// 内存占用:2字节
// 精度:约2-3位小数(比FP16略差)
// 范围:±3.4e38(和FP32相同!)
// 用途:训练大模型,保持FP32的数值范围

三者对比:

类型 大小 精度 范围 速度 应用
FP16 2B 最快 推理
BF16 2B 训练
FP32 4B 最高 高精度计算

我的使用经验:

  • 推理:优先FP16,速度快,精度够
  • 训练:用BF16或FP32,避免梯度下溢
  • 中间计算:可以用FP32累加,输入输出用FP16

1.3 向量类型

NPU支持向量运算,一条指令处理多个数据:

// FP16向量(最常用)
half8   vec8;   // 8个half,16字节
half16  vec16;  // 16个half,32字节
half32  vec32;  // 32个half,64字节

// FP32向量
float8  vec8;   // 8个float,32字节
float16 vec16;  // 16个float,64字节

// INT8向量
int8x32  vec32;  // 32个int8,32字节
int8x64  vec64;  // 64个int8,64字节

为什么向量化重要?

// 标量方式:256条指令
for (int i = 0; i < 256; i++) {
    z[i] = x[i] + y[i];  // 每次处理1个元素
}

// 向量方式:16条指令(FP16向量宽度=16)
Add(z, x, y, 256);  // 每次处理16个元素,快16倍!

二、内存层次结构

2.1 NPU的内存架构

NPU有多级内存,理解它们很关键

2.2 各级内存详解

DDR/HBM (外部内存)
// 通常不直接操作,通过AscendCL API管理
void* devPtr;
aclrtMalloc(&devPtr, size, ACL_MEM_MALLOC_HUGE_FIRST);

特点:

  • ✅ 容量大(GB级)
  • ❌ 速度慢
  • 用途:存储模型参数、输入输出数据
Global Memory (GM)
__gm__ half* gmPtr;  // GM指针

GlobalTensor<half> gmTensor;
gmTensor.SetGlobalBuffer(gmPtr);

特点:

  • ✅ 容量较大(几GB)
  • ⚠️ 速度一般
  • 用途:Kernel的输入输出,临时存储
Unified Buffer (UB)
__ubuf__ half ubArray[1024];  // UB数组

LocalTensor<half> ubTensor;

特点:

  • ✅ 速度快(~1TB/s)
  • ❌ 容量小(几MB)
  • 用途:计算时的工作区,Tiling的缓冲区

这是我们算子开发中最常用的内存!

L0 Buffer
// 通常由编译器自动管理,很少手动操作

特点:

  • ✅ 速度最快(寄存器级)
  • ❌ 容量极小(几KB)
  • 用途:向量计算的临时寄存器

2.3 内存访问模式

不同内存之间的数据搬运:

// 1. GM -> UB (最常见)
LocalTensor<half> ubTensor = queue.AllocTensor<half>();
DataCopy(ubTensor, gmTensor[offset], count);

// 2. UB -> GM
DataCopy(gmTensor[offset], ubTensor, count);

// 3. UB -> UB (很少用)
DataCopy(ubTensor2, ubTensor1, count);

// 4. GM -> GM (不推荐,效率低)
// 应该通过UB中转

我踩过的坑:直接GM到GM拷贝

// ❌ 错误:直接GM到GM(不支持或效率低)
DataCopy(gmTensor2, gmTensor1, count);

// ✅ 正确:通过UB中转
LocalTensor<half> temp = queue.AllocTensor<half>();
DataCopy(temp, gmTensor1, count);
DataCopy(gmTensor2, temp, count);
queue.FreeTensor(temp);

三、内存对齐

3.1 为什么要内存对齐?

NPU硬件要求数据按特定边界对齐,才能高效访问。就像快递打包,按固定规格打包效率更高。

3.2 对齐规则

CANN的对齐要求:

// FP16: 32字节对齐(16个half)
constexpr uint32_t TILE_SIZE_FP16 = 256;  // ✅ 256 * 2 = 512B,是32的倍数

// FP32: 32字节对齐(8个float)
constexpr uint32_t TILE_SIZE_FP32 = 256;  // ✅ 256 * 4 = 1024B,是32的倍数

// 不对齐的例子
constexpr uint32_t TILE_SIZE_BAD = 100;  // ❌ 100 * 2 = 200B,不是32的倍数

3.3 对齐检查

// 检查地址是否对齐
bool IsAligned(void* ptr, size_t alignment) {
    return (reinterpret_cast<uintptr_t>(ptr) % alignment) == 0;
}

// 使用
if (!IsAligned(devPtr, 32)) {
    printf("Warning: pointer not aligned!\n");
}

3.4 我的对齐踩坑经历

有一次我写了个算子,Tile大小设成100:

constexpr uint32_t TILE_SIZE = 100;  // ❌
pipe.InitBuffer(queue, 2, TILE_SIZE * sizeof(half));  // 200字节

运行报错:

[ERROR] Memory alignment error

改成128后就好了:

constexpr uint32_t TILE_SIZE = 128;  // ✅
pipe.InitBuffer(queue, 2, TILE_SIZE * sizeof(half));  // 256字节

四、内存分配与释放

4.1 Host端内存管理

// 方法1:使用aclrtMalloc (推荐)
void* devPtr = nullptr;
aclError ret = aclrtMalloc(&devPtr, size, ACL_MEM_MALLOC_HUGE_FIRST);
if (ret != ACL_SUCCESS) {
    printf("Malloc failed\n");
    return -1;
}

// 使用...

// 释放
aclrtFree(devPtr);

// 方法2:使用aclrtMallocHost (Host内存,可以零拷贝访问)
void* hostPtr = nullptr;
aclrtMallocHost(&hostPtr, size);
// ...
aclrtFreeHost(hostPtr);

4.2 Device端内存管理

在Kernel内部,使用Queue管理:

// 初始化Buffer
pipe.InitBuffer(queue, BUFFER_NUM, TILE_SIZE * sizeof(half));

// 分配Tensor
LocalTensor<half> tensor = queue.AllocTensor<half>();

// 使用...

// 释放
queue.FreeTensor(tensor);

关键点

  • AllocTensorFreeTensor 必须配对
  • 忘记Free会导致内存泄漏
  • 多次Free同一个Tensor会crash

4.3 内存泄漏检测

我写了个简单的检测工具:

class MemTracker {
private:
    static int allocCount;
    static int freeCount;
    
public:
    static void OnAlloc() { allocCount++; }
    static void OnFree() { freeCount++; }
    
    static void Report() {
        printf("Alloc: %d, Free: %d\n", allocCount, freeCount);
        if (allocCount != freeCount) {
            printf("⚠️ Memory leak detected!\n");
        }
    }
};

// 使用
LocalTensor<half> tensor = queue.AllocTensor<half>();
MemTracker::OnAlloc();

// ...

queue.FreeTensor(tensor);
MemTracker::OnFree();

// 程序结束时
MemTracker::Report();

五、数据类型转换

5.1 显式转换

// FP32 -> FP16
float f32 = 1.5f;
half h16 = static_cast<half>(f32);

// FP16 -> FP32
half h16 = 1.5;
float f32 = static_cast<float>(h16);

// 整数 -> 浮点
int32_t i32 = 100;
half h16 = static_cast<half>(i32);

5.2 向量转换

CANN提供了向量转换API:

// FP32 -> FP16 (向量)
LocalTensor<float> fp32Tensor;
LocalTensor<half> fp16Tensor;

Cast(fp16Tensor, fp32Tensor, count, CastMode::FP32_TO_FP16);

// FP16 -> FP32 (向量)
Cast(fp32Tensor, fp16Tensor, count, CastMode::FP16_TO_FP32);

// INT32 -> FP16
Cast(fp16Tensor, int32Tensor, count, CastMode::INT32_TO_FP16);

5.3 精度损失问题

FP32转FP16会损失精度:

float f32 = 1.234567f;  // FP32精度
half h16 = static_cast<half>(f32);
float f32_back = static_cast<float>(h16);

printf("Original: %.7f\n", f32);        // 1.2345670
printf("After cast: %.7f\n", f32_back); // 1.2343750 (精度损失!)

我的经验:

  • 如果精度要求高,中间计算用FP32,最后再转FP16
  • 梯度累加一定要用FP32,否则会下溢

六、Tensor操作

6.1 Tensor的创建

// 方法1:通过Queue分配
LocalTensor<half> tensor1 = queue.AllocTensor<half>();

// 方法2:设置Global Buffer
GlobalTensor<half> tensor2;
tensor2.SetGlobalBuffer((__gm__ half*)ptr);

// 方法3:栈上分配(小数据量)
__ubuf__ half buffer[128];
LocalTensor<half> tensor3 = *(LocalTensor<half>*)buffer;

6.2 Tensor的基本操作

// 获取大小
uint32_t size = tensor.GetSize();

// 访问元素(不推荐,用向量操作)
half value = tensor.GetValue(index);

// 设置元素(不推荐)
tensor.SetValue(index, value);

// 获取指针(谨慎使用)
half* ptr = tensor.GetData();

6.3 Tensor的拷贝

// 完整拷贝
DataCopy(dstTensor, srcTensor, count);

// 带偏移的拷贝
uint32_t srcOffset = 100;
uint32_t dstOffset = 0;
DataCopy(dstTensor[dstOffset], srcTensor[srcOffset], count);

// 带步长的拷贝(某些场景)
DataCopyPad(dstTensor, srcTensor, count, stride);

七、内存优化技巧

7.1 减少数据搬运

// ❌ 不好:频繁搬运
for (int i = 0; i < 100; i++) {
    DataCopy(ub, gm[i], 1);     // 搬运1个元素,100次
    Compute(ub, 1);
    DataCopy(gm[i], ub, 1);
}

// ✅ 好:批量搬运
DataCopy(ub, gm, 100);          // 搬运100个元素,1次
Compute(ub, 100);
DataCopy(gm, ub, 100);

7.2 数据复用

// ❌ 不好:重复搬运
DataCopy(ub, gm[0], size);  // 搬运一次
Compute1(ub);
FreeTensor(ub);

ub = AllocTensor();
DataCopy(ub, gm[0], size);  // 又搬运一次(数据没变!)
Compute2(ub);

// ✅ 好:复用数据
DataCopy(ub, gm[0], size);  // 只搬运一次
Compute1(ub);
Compute2(ub);               // 复用数据
FreeTensor(ub);

7.3 内存池

对于频繁分配释放,可以用内存池:

class TensorPool {
private:
    std::vector<LocalTensor<half>> pool;
    
public:
    LocalTensor<half> Acquire() {
        if (pool.empty()) {
            return queue.AllocTensor<half>();  // 新分配
        } else {
            LocalTensor<half> tensor = pool.back();
            pool.pop_back();
            return tensor;  // 复用
        }
    }
    
    void Release(LocalTensor<half> tensor) {
        pool.push_back(tensor);  // 回收
    }
};

八、常见错误及解决

错误1:内存越界

[ERROR] Memory access out of bounds

原因:

constexpr uint32_t TILE_SIZE = 256;
pipe.InitBuffer(queue, 2, TILE_SIZE * sizeof(half));  // 分配256个元素

LocalTensor<half> tensor = queue.AllocTensor<half>();
DataCopy(tensor, gm, 512);  // ❌ 拷贝512个元素,越界!

解决:确保拷贝大小不超过Buffer大小

错误2:内存未对齐

[ERROR] Memory alignment error, addr=0x...

原因:Tile大小不是32字节的倍数

解决:调整TILE_SIZE为32字节的倍数

错误3:内存泄漏

现象:运行一段时间后,可用内存越来越少

# 用npu-smi监控
watch -n 1 npu-smi info

原因:忘记FreeTensor或aclrtFree

解决:确保每个Alloc都有对应的Free

九、实战案例:手动内存管理

完整示例:

class MyKernel {
public:
    __aicore__ inline void Process() {
        // 1. 分配内存
        LocalTensor<half> input1 = queueIn.AllocTensor<half>();
        LocalTensor<half> input2 = queueIn.AllocTensor<half>();
        LocalTensor<half> temp = queueTemp.AllocTensor<half>();
        LocalTensor<half> output = queueOut.AllocTensor<half>();
        
        // 2. 拷贝数据
        DataCopy(input1, gmInput1, TILE_SIZE);
        DataCopy(input2, gmInput2, TILE_SIZE);
        
        // 3. 计算
        Mul(temp, input1, input2, TILE_SIZE);     // temp = input1 * input2
        Add(output, temp, input1, TILE_SIZE);     // output = temp + input1
        
        // 4. 拷贝结果
        DataCopy(gmOutput, output, TILE_SIZE);
        
        // 5. 释放内存(重要!)
        queueIn.FreeTensor(input1);
        queueIn.FreeTensor(input2);
        queueTemp.FreeTensor(temp);
        queueOut.FreeTensor(output);
    }
    
private:
    TPipe pipe;
    GlobalTensor<half> gmInput1, gmInput2, gmOutput;
    TQue<QuePosition::VECIN, 2> queueIn;
    TQue<QuePosition::VECCALC, 2> queueTemp;
    TQue<QuePosition::VECOUT, 2> queueOut;
};

十、总结

CANN数据类型和内存管理要点:

数据类型

  • FP16:推理首选,速度快
  • BF16:训练友好,范围大
  • FP32:高精度计算
  • 向量类型:性能关键

内存层次

  • DDR/HBM:容量大,速度慢
  • Global Memory:中转存储
  • Unified Buffer:计算工作区(最常用)
  • L0 Buffer:寄存器级(编译器管理)

关键原则

  1. ✅ 内存对齐(32字节)
  2. ✅ 分配释放配对
  3. ✅ 减少数据搬运
  4. ✅ 数据复用
  5. ✅ 向量化访问

理解了这些,就能写出高效的CANN算子。下一篇我会讲Tensor的高级操作,包括shape变换、数据排布等。


相关文章推荐

  • 上一篇:第一个Hello World算子开发实战
  • 下一篇:Tensor操作基础:理解张量在NPU中的运作

练习建议
试着修改Hello World算子,换成FP32数据类型,观察性能差异。

有问题欢迎留言!点赞收藏支持一下~

Logo

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

更多推荐