昇腾310P使用记录

概述

课题组最近的项目需要用到华为的昇腾计算卡,和CUDA汗牛充栋的教程和文档相比,作为一款比较新的计算卡产品,昇腾在网上基本没什么教程,可以参考的只有官方文档、官方代码仓库和官方论坛。因此我在使用的过程中,也经过了很多探索,踩了不少坑,所以在这里记录一下我遇到的一些问题和解决方案。

特别强调一下,我使用的是Atlas 300I这个型号的昇腾卡,其中的处理器是昇腾310P(Ascend 310P),具体SOC版本是Ascend310P3。这点很重要,因为昇腾的API兼容性比较差,同个API对不同型号的处理器支持差别很大,因此在查看文档的时候一定要注意检查这个API是否支持自己的处理器,以及自己的处理器支持哪些模板参数组合、哪些重载版本。如果使用了自己的处理器不支持的API,程序不会报异常,而是直接跳过该API的调用,到时候调试排错能让人心态崩溃🤪!本文只针对我所用的昇腾310P处理器。

另外一个要注意的地方是文档,随着CANN库的升级,昇腾的官方文档也在不断迭代,在这过程中修正老文档中的错误。所以要看就要看最新的,而且得是社区版文档。比方说我就在商用版的文档中发现了一个错误,本来想去论坛上提问的,后来看了社区版文档发现已经改了,然后我又在社区版文档里找到了一个商业版文档代码示例里漏加的语句,加上以后一个之前一直跑不通的程序就跑通了,我也是无语😅。总而言之,不要看商用版文档,要看最新的社区版文档。截至本文撰写时,最新版本的社区文档是8.0.RC3.alpha002。本文也只针对这个版本的CANN库。

Ascend CL

Ascend CL(ACL)是CANN库的上层API,主要负责初始化、内存管理、调用官方算子、执行部署好的模型推理等操作。我使用的是Python版本的API,因为可以用Numpy来执行数据的预处理和后处理等操作,比用C++方便不少。大部分的内容文档里都讲的很清楚了,我就只说几个点。

内存管理

和GPU类似,在执行计算前需要把主机DRAM的数据搬移到昇腾卡的DRAM上。这里我写了一个类,方便管理设备缓冲:

class Buffer():
    def __init__(self, size, shape=None, acltype=None):
        # size以字节为单位
        # 如果这个显存需要用在模型推理的输入输出,则要给出shape和acltype参数,这样才能生成张量描述和数据缓冲结构供API调用
        self.array, ret = acl.rt.malloc(size, ACL_MEM_MALLOC_HUGE_FIRST)
        assert(ret == ACL_SUCCESS)
        self.size = size
        if shape is not None and acltype is not None:
            self.desc = acl.create_tensor_desc(acltype, shape, ACL_FORMAT_ND)
            self.buffer = acl.create_data_buffer(self.array, size)
        else:
            self.desc = None
            self.buffer = None

    def from_np(self, a):
        # 从numpy复制到显存
        ret = acl.rt.memcpy(self.array, self.size, a.ctypes.data, self.size, ACL_MEMCPY_HOST_TO_DEVICE)
        assert(ret == ACL_SUCCESS)

    def to_np(self, a):
        # 从显存复制到numpy
        ret = acl.rt.memcpy(a.ctypes.data, self.size, self.array, self.size, ACL_MEMCPY_DEVICE_TO_HOST)
         assert(ret == ACL_SUCCESS)

     def __del__(self):
         if self.desc is not None:
             acl.destroy_tensor_desc(self.desc)
         if self.buffer is not None:
             ret = acl.destroy_data_buffer(self.buffer)
         ret = acl.rt.free(self.array)

注意三点:

  • 由于ACL在执行模型推理的时候还需要输入输出张量的具体描述信息,所以这里我就在创建设备缓冲的同时顺便也把这些描述信息一起生成了,放在self.descself.buffer属性里。
  • 这里和numpy的交互过程我没有用官方的APInumpy_to_ptr/bytes_to_ptr,前者即将废弃,后者会有额外的复制操作,总之都有点问题,不如直接用numpy官方的返回地址方法array.ctypes.data,注意如果调用前numpy数组物理地址不连续(例如经过了转置),需要调用numpy.ascontiguousarray把它弄成连续的。
  • Python变量的析构时间不确定,而ACL在finalize会自动释放显存,因此如果是在调用finalize在执行析构函数就会报错,所以需要在调用finalize前使用del XXX手动析构。

调用官方算子

文档写得太复杂了,总结起来分为以下三步:

  1. 写算子描述json文件,里面包含了要调用的所有算子的输入输出张量大小、格式等
  2. 使用ATC工具讲json文件转成om文件
  3. 程序里加载om文件,初始化,申请资源,调用acl.op.execute_v2,释放资源等

具体操作可以参考代码仓库,可能只有C++版本的,不过Ascend CL的Python版本和C++版本API基本都是一一对应的关系,很容易翻译过来。json文件的写法以及每种算子如何描述都可以在文档中查到。

这里有一个比较容易让人误解的地方,就是所谓的CBLAS接口,我开始以为这玩意和cuBLAS一样,是一套可以直接调用的矩阵乘法API,结果用的时候一直报错。后来才知道这玩意本质上是acl.op.execute_v2套皮,内部执行的是GEMM算子,所以还是得走前面那三步,只不过能少声明几个结构体罢了。有点不明白这个API的意义是啥,误导cuBLAS用户吗🤔?

模型推理

调用官方算子的时候需要执行多少函数就得在json文件里声明多少算子,其中的中间张量也得手动执行申请显存、申请张量描述结构体等操作,极其麻烦。所以不仅是神经网络模型,普通的需要通过执行多个函数来完成的操作也建议构建成模型然后让ACL一次性执行完。模型推理也分为以下三步:

  1. 准备ONNX模型
  2. 使用ATC工具讲ONNX文件转成om文件
  3. 程序里加载om文件,初始化,申请资源,调用acl.mdl.execute,释放资源等

ONNX模型我直接用PyTorch生成:

class model(torch.nn.Module):
    def __init__(self):
        super(model, self).__init__()
        # ...
    def forward(self, x):
        # ...
constructadc = model()
x = torch.rand(1, 128)
torch.onnx.export(
        model, x, 'model.onnx', export_params=True,
        input_names = ['x'], output_names = ['r'])

我用如下的命令转换:

atc --model=model.onnx --framework=5 --output=model --input_shape="x:1,128" --soc_version=Ascend310P3

要进一步提高精度,可以添加--precision_mode_v2=origin参数,不过推理效率会低不少。

Ascend C

有时我们需要对算子内部的存储层次和循环结构进行更精细的控制,Ascend C就可以满足我们的要求,实际上它对应的也是Nvidia的CUDA算子。不过虽然Ascend C和CUDA一样用的都是C++语言,调用语法也有些相像,但实际上两者的编程模式差别很大,因为昇腾的架构和GPU的架构本身就有着决定性的不同。这里我介绍一下自己所用的调用方式,对其和GPU架构的比较以及用API时遇到的一些坑点。

调用方式

在本文中,我采用的是Pybind11的调用方式,和我之前的文章《自定义CUDA实现PyTorch算子的四种简单方法》比较类似,用一个源文件写算子,一个源文件写接口,不过这个就不能让Pytorch Extension自动编译了,而需要自己用CMake和Make编译,具体可以参考样例代码,里面的CMakeFile可以直接拿来改,把源代码文件名换掉就可以了。样例代码里函数接口传入的是Pytorch张量,如果没用Pytorch的话,可以把函数的参数类型改成uint64_t,然后在Python端直接把之前acl.rt.malloc返回的指针传进去。

另外,我在编译的时候报错提示算子文件找不到其引用的头文件,这个头文件是C++标准库里的,按理来说正常编译都是会自动引用。代码仓库里有人在Issue里提过类似的问题,但最后通过更新库的版本、切换环境什么的就解决了,只有我一直解决不了,最后临时用以下命令解决了:

CPLUS_INCLUDE_PATH=/usr/include/c++/9/:/usr/include/aarch64-linux-gnu/c++/9/ make -j

其实就是在编译的时候强制让编译器额外去这些目录找头文件。make -j前面这一段也可以加在bash run.sh前面(如果是用一键脚本进行编译的话),或者直接用export声明环境变量。这个问题不一定每个人都会遇到,我怀疑可能和服务器架构有关,之前师兄想把卡装在已有的服务器上结果识别不了,没办法只能采购华为自己出的服务器,华为服务器用的是ARM架构的鲲鹏CPU,而昇腾CANN库的开发人员用的可能是x64的,所以可能就会出现BUG,当然这也只是猜测,反正指定一下环境变量基本就没什么问题了。

编程模式

Ascend C虽然在上层API上和CUDA挺像的,但编程模式和CUDA相差很大。原因是GPU由大量计算核(CUDA core)组成,本质上是同构架构,开发者所写的程序跑在这些计算核上,每个计算核跑一样的程序,因此编程模式也比较类似于使用OpenMP编程。而昇腾计算卡则是由少量AI核组成,而每个AI核内部又由AI cpu、Vector core和Cube core组成,本质上是异构架构,开发者所写的程序跑在AI cpu上,同时AI cpu将程序中的任务调度给Vector core和Cube core,因此编程模式比较类似于使用Numpy编程,需要通过固定的几种Ascend C API的组合来构建整个程序,这些Ascend C API所对应的就是Vector core和Cube core所支持的计算操作。

这两种编程模式,在我看来,互有优劣。Ascend C的优势在于编程简单,像Numpy上手十分轻松,而OpenMP在考虑并行数据流方面则需要下一番功夫。同时,Ascend C的编程模式也实现了对大部分架构细节的隐藏,像缓存、地址对齐这些基本不太需要考虑;而CUDA则不一样,读写合并、bank conflict这些都是高性能编程必须考虑的点,如果没有处理好,将会引起大量额外开销。此外,Ascend C代码跑在AI cpu上,本质上是Arm架构的处理器,编程方式和普通平台上没有任何不同;而CUDA编程的是CUDA core,和普通平台相比不擅长做分支,指令调度也不太一样,需要额外学习。因此,在教程和文档齐备的情况下,Ascend C应该是比CUDA好上手很多的。

CUDA则胜在它的灵活性,Ascend C在编程上只能用它所提供的API,对于一些比较复杂的任务,要么无法用现有的API实现,只能通过AI cpu来实现,效率很低,要么需要组合API,通过绕弯子的方式实现,需要大量的额外开销。而CUDA则不一样,理论上可以以并行的方式实现一切C++能够实现的功能。此外,GPU现在引入了Tensor core,在架构上也偏异构了;而昇腾的架构则很难通过改进来执行图形计算,限制还是比较多的。

排序API

注意这里讨论的排序API不包括TopK,因为它和其他API差别太大,而且我没用过,所以不敢妄言。

昇腾310P(其他处理器不一定一样)上的Ascend C排序API的核心数据结构都是由n个Region Proposal结构体组成的数组,其中n是要排序的数据个数,Region Proposal结构体包含8个属性,其中score属性是排序关键字,另外七个属性都可以自由存储数据。API包含三种:

  • 合成数据API:用来将一个数组复制到前面所说的Region Proposal数组中的某个属性。包含两个函数:
    • 架构无关函数Concat。这个函数参数很直观,包含输入张量srcLocal、输出张量dstLocalRegion Proposal数组),要复制到哪个属性modeNumber,要复制的元素组数repeatTimes
    • 少数几种处理器(包含Ascend 310P)专属的函数ProposalConcat。这个函数的参数就比较奇葩:没有指定目标属性,同时还有个临时张量参数tmpLocal。如果看它的用法,更奇怪了,临时张量参数tmpLocal给出的是Region Proposal数组,而输出张量参数concatLocal给出的则是一个空张量。经过实验,结论有两点:
      • 目标属性是score,这也容易理解,毕竟输入是待排序的关键字数组;
      • 函数执行后的concatLocal实际上和tmpLocal指向同一片存储区域,也就是说,这个函数把数据复制到Region Proposal数组tmpLocal里以后,让空的concatLocal作为引用指向了tmpLocal。所以我觉得这个函数的设计其实挺迷惑的,为啥不直接去掉tmpLocal这个参数,然后要求用户把分配好足够空间的张量传给concatLocal呢?这样设计不仅奇怪,还容易误导用户,用户可能调用完函数把tmpLocal拿去做别的用途,结果就会把concatLocal的内容也改了。这里就是一个大坑,所以建议如果程序专门就是针对310P的,完全没必要用这个函数,功能又少又易错,昇腾官方也最好考虑一下是否要重新设计这个函数。
  • 排序执行API:主要包含SortMrgSort这两个,还有一些架构专属API,由于用途比较狭窄(比如只能16个16个排序),所以就不提了。Sort我觉得设计也有点问题,为什么要把排序和索引复制混在同一个函数中做呢?一方面用户不知道索引会被复制到Region Proposal的哪个属性(实验证明是y1属性);另一方面可能我只想把一部分索引复制到数组里,然后整个数组排序,但这个函数就强迫我每次执行的时候复制索引的数量都必须和待排序的数组的大小一致,这就会带来额外的开销。MrgSort挺直观的,没什么坑点,看看样例代码就会用了,注意商业版文档(我看的是8.0.RC2.2,8.0.RC3已更正)关于isExhaustedSuspension模板参数的解释写反了,新的社区版文档解释是对的。
  • 分离数据API:是合成数据API的反操作,用来将Region Proposal数组中的某个属性复制到普通数组中,同样包含架构无关函数Extract和架构专属函数ProposalExtract,后者和ProposalConcat是对应的;前者也很直观,就是把scorelabel两个属性分别复制到两个数组,还是挺容易理解的。

此外,Region Proposal数组的类型虽然只能是floathalf,但实际上除了排序关键字之外的属性想存什么类型的数都是可以的。直接取一个其他类型的LocalTensor写数据,然后对这个LocalTensor所属的TQue/TBufEnQue+DeQue/Get取一个float/halfLocalTensor,再把它复制到Region Proposal数组里即可。实际上Sort函数内部应该也是这样处理索引的。

但这里就有一个坑,如果索引超过了65535,而数据类型是half,那就会出问题,毕竟half就16位,不管怎么变换类型都无法准确存下这么大的数,更重要的是,由于Sort接收的索引类型是uint32_t,所以用户在生成索引的时候可能一直用的都是32位整数,没意识到half类型的Region Proposal存不下。解决方法要么改用float类型的Region Proposal,要么就是将索引拆位,把高位和低位拆分到除了score之外的各个属性中,保证全部属性都小于65536,不过这就比较麻烦。

Update: 前面虽然说用16位类型的Region Proposal比较麻烦,但Region Proposal数组动不动就消耗普通数组八倍的存储,占用Unified Buffer的资源量和排序延迟都比较高,所以有时还是不得不用16位类型。但这又会遇到一个非常坑爹的问题:Ascend310P上没有16位整数转32位整数的API!导致我从Region proposal数组中获得的索引无法转回32位整数。Ascend310P上Cast函数只能将int16_t转成half,会损失精度;而如果用Scatter,延迟又非常高。

还好,前面说的Extract函数可以派上用场,这个函数不管输入的Region Proposal是什么类型,输出的索引类型都是uint32_t,这就为我们的需求创造了条件,对于需要转回int32_t的索引,可以先用ProposalExtract以16位的形式保存到张量中,然后再用ProposalConcat将索引放到Region proposal的1号位置(也就是y1属性),然后用Extract,就能将索引以32位的形式取出了😎。绕了一大圈,希望昇腾以后能开放Ascend310P可用的转换函数吧。

同步API

官方代码样例基本都是非常规整的流水线程序,因此用的是TQue来存储数据,但实际需求中可能有些数据会反复被重用,这时还是用TBuf来存储好一些,但是TBuf没有自动同步功能,所以如果从TBuf中取的LocalTensor经过API计算后要复制到GlobalTensor,或者GlobalTensor中的数据要复制到TBuf中取的LocalTensor然后进行API计算,此时就需要同步。具体同步API可以查看文档中的TQueSync相关,虽然同步时要Set一下Wait一下而不是只用一个函数很怪,但总体还是很容易理解的。

矩阵乘法API

由于我做的项目没怎么用到矩阵乘法API,所以我对其也不是很了解,这里仅记录一下我使用矩阵乘法API的流程,后面可能还会再补充原理和优化方法之类的。

首先是在Host端声明一个TCubeTiling,把它复制到设备内存中:

// m*k,k*n
auto ascendcPlatform = platform_ascendc::PlatformAscendCManager::GetInstance();
matmul_tiling::MatmulApiTiling cubeTiling(*ascendcPlatform);
cubeTiling.SetAType(matmul_tiling::TPosition::VECOUT, matmul_tiling::CubeFormat::ND, matmul_tiling::DataType::DT_FLOAT16);
cubeTiling.SetBType(matmul_tiling::TPosition::VECOUT, matmul_tiling::CubeFormat::ND, matmul_tiling::DataType::DT_FLOAT16);
cubeTiling.SetCType(matmul_tiling::TPosition::VECIN, matmul_tiling::CubeFormat::ND, matmul_tiling::DataType::DT_FLOAT16);
cubeTiling.SetShape(m, n, k);
cubeTiling.SetOrgShape(m, n, k);
cubeTiling.SetBias(false);
optiling::TCubeTiling tiling;
if (cubeTiling.GetTiling(tiling) == -1) return 0;

uint32_t tiling_size = tiling.GetDataSize();
uint64_t device_tiling;
aclError err = aclrtMalloc((void **)&device_tiling, tiling_size, ACL_MEM_MALLOC_HUGE_FIRST);
if (err != ACL_SUCCESS) return 0;
uint8_t *host_tiling = new uint8_t[tiling_size];
tiling.SaveToBuffer(host_tiling, tiling_size);
err = aclrtMemcpy((void *)device_tiling, tiling_size, host_tiling, tiling_size, ACL_MEMCPY_HOST_TO_DEVICE);
if (err != ACL_SUCCESS) {
    delete[] host_tiling;
    aclrtFree((void *)device_tiling);
    return 0;
}
delete[] host_tiling;

然后在设备端把显存中的TCubeTiling复制过来(样例代码是这样写的,但我觉得是不是把__gm__ uint8_t*直接强转成TCubeTiling*是不是也可以):

// __gm__ uint8_t *tiling
TCubeTiling tctiling;
int *dst = reinterpret_cast<int *>(&tctiling);
auto src = reinterpret_cast<__gm__ int *>(tiling);
for (int i = 0; i < sizeof(TCubeTiling) / sizeof(int); i++) {
    dst[i] = src[i];
}

然后声明Matmul类和注册。注意这Matmul类和《西游记》里的人参果似的,人参果摘果比吃果还麻烦,而Matmul声明也比调用麻烦:

  • Matmul声明完必须立即REGIST_MATMUL_OBJ,不能分开,包括把Matmul声明成类里的成员变量也不行。否则就会程序就会卡死,非常奇怪,我想不出原因,但事实就是如此。
  • Matmul不能作为类里引用类型的成员变量。比方说在类里声明了一个成员引用,然后在主函数里声明一个Matmul,注册后再通过类的构造函数把Matmul的引用传进去,这也是不行的。而且很奇怪,似乎如果只执行一次矩阵乘法是没问题的,但执行多次矩阵乘法就会结果错误。

最后的做法是在类里的一个成员函数里声明成局部变量,注册后把引用作为参数传给另一个成员函数,就可以了,反正很玄乎:

using namespace matmul;
using matmul_t = Matmul<MatmulType<AscendC::TPosition::VECOUT, CubeFormat::ND, half>,
      MatmulType<AscendC::TPosition::VECOUT, CubeFormat::ND, half>,
      MatmulType<AscendC::TPosition::VECIN, CubeFormat::ND, half>>;
matmul_t mm;
REGIST_MATMUL_OBJ(&pipe, GetSysWorkSpacePtr(), mm, &tiling);
for (/*...*/) {
    // ...
    Sum(mm);
    // ...
}

然后是具体执行:

mm.SetTensorA(A_ten, false);
mm.SetTensorB(B_ten, false);
mm.IterateAll(C_ten);
mm.End();

注意如果输入输出矩阵中包含GlobalTensor的话需要额外调用一个mm.SetLocalWorkspace方法(我试的结果是这样,但和文档有点出入,建议自己亲自在Host端执行MatmulGetTmpBufSize看看返回值,如果是0就可以不调用),这个商业版文档也没提(我看的是8.0.RC2.2),坑死我了😤。

杂项

这里谈一下Ascend C里Tensor的类型转换,如果是数值相关的转换,比如浮点1.0转成整数1这种,则使用Cast函数,要注意两点:

  • 看清文档里每个架构Cast所支持的舍入模式,比如floathalf,文档前面介绍了好几种舍入模式,结果到了后面又说310P只支持CAST_NONE/CAST_ODD,如果用了处理器架构所不支持的舍入模式,程序也不会报错,就默默地啥也不做,非常坑爹。
  • 有些类型之间不能直接转换,这就需要两次或者多次Cast,比如uint8_tint32_t,就需要先转成half再转成int32_t,具体也是要看处理器架构支持的转换类型。

如果是二进制级别的转换,比如int32_tuint32_t,如果要转换的数是非负数,那么转换前后的二进制表示是一样的。这时就直接对要转换的LocalTensor所属的TQue/TBufEnQue+DeQue/Get重新取一个转换后类型的LocalTensor即可,因为TQue/TBuf是无类型的,所以在二进制级别上可以解释成任意类型。

另外关于高阶API的ArithProgression函数,虽然可以指定它生成整数序列,但它的内部应该是用浮点数来实现的,因此当整数序列比较大时,会产生精度误差,这点需要注意。比如说我想生成一个一亿到一亿加一万的整数序列,由于浮点数的尾数没有那么多位,到时候可能每个数都和预期的差个几或十几。正确做法是生成零到一万的整数序列,然后再用Add把序列所有数都加上一亿,这样就没有精度问题了。

总结

总体而言,我觉得昇腾的文档写的还可以,大部分内容都有附上示例代码,总体脉络还是挺清晰的。但项目需求千千万,示例代码也无法覆盖所有的情况,这时就会遇到坑了。CUDA为什么用的人多,就是有足够牛的项目验证了它的可行性,同时基于它的无数项目也都把坑都踩光了,遇到什么问题网上大体都搜得到。相比之下,国内以昇腾为代表的各种GPU、计算卡在这方面还存在不少差距。所以我在这里把遇到的问题记录下来,既是方便自己回忆,也是分享给大家,希望能够为我国的科技发展作出贡献🤗。

posted @ 2024-10-02 11:19  YuanZiming  阅读(865)  评论(4编辑  收藏  举报