昇騰Ascend C編程入門教程(純干貨)

2023年5月6日,在昇騰AI開發(fā)者峰會上,華為正式發(fā)布了面向算子開發(fā)場景的昇騰Ascend?C編程語言。Ascend?C原生支持C/C++編程規(guī)范,通過多層接口抽象、并行編程范式、孿生調(diào)試等技術(shù),極大提高了算子的開發(fā)效率,幫助AI開發(fā)者低成本完成算子開發(fā)和模型調(diào)優(yōu)部署。
1?昇騰AI軟硬件基礎(chǔ)
和CUDA開發(fā)的算子運行在GPU上一樣,基于Ascend?C開發(fā)的算子,可以通過異構(gòu)計算架構(gòu)CANN(Compute Architecture for Neural Networks)運行在昇騰AI處理器(可簡稱NPU)上。CANN是使能昇騰AI處理器的一個軟件棧,通過軟硬件協(xié)同優(yōu)化,能夠充分發(fā)揮昇騰AI處理器的強大算力。從下面的架構(gòu)圖可以清楚的看到,使用Ascend?C編程語言開發(fā)的算子通過編譯器編譯和運行時調(diào)度,最終運行在昇騰AI處理器上。

我們知道,通用計算就是我們常寫的一些在CPU上運行的計算,它擅長邏輯控制和串行計算,而AI計算相對通用計算來說,更擅長并行計算,可支持大規(guī)模的計算密集型任務。如下面左圖所示,做一個矩陣乘,使用CPU計算需要三層for循環(huán),而右圖在昇騰AI處理器上使用vector計算單元,只需要兩層for循環(huán),最小計算代碼能同時計算多個數(shù)據(jù)的乘加,更近一步,如果使用Cube計算單元,只需要一條語句就能完成一個矩陣乘的計算,這就是我們所說的SIMD(單指令多數(shù)據(jù))。因此,我們通常使用AI處理器來進行大量的并行計算。

NPU不能獨立運行,需要與CPU協(xié)同工作,可以看成是CPU的協(xié)處理器,CPU負責整個操作系統(tǒng)運行,管理各類資源并進行復雜的邏輯控制,而NPU主要負責并行計算任務。在基于CPU+NPU的異構(gòu)計算架構(gòu)中,NPU與CPU通過PCIe總線連接在一起來協(xié)同工作,CPU所在位置稱為主機端(host),而NPU所在位置稱為設備端(device),示意圖如下:

這里再詳細介紹一下昇騰AI處理器。昇騰AI處理器有不同的型號和產(chǎn)品形態(tài),小到模塊、加速卡,大到服務器、集群。昇騰AI處理器里面最核心的部件是AI?Core,有多個,是神經(jīng)網(wǎng)絡加速的計算核心,每一個AI?Core就相當于我們大家平時理解的多核cpu里的每個核,使用Ascend C編程語言開發(fā)的算子就運行在AI Core上,因為核心的神經(jīng)網(wǎng)絡計算的加速都來源于AI?Core的算力。
AI?Core內(nèi)部的并行計算架構(gòu)抽象如下圖所示:

這個并行計算架構(gòu)抽象核心包含了幾個大的部件,AI?Core外面有一個Gobal?Memory,是多個AI?Core共享的,在AI?Core內(nèi)部有一塊本地內(nèi)存Local?Memory,因為靠近計算單元,所以它的帶寬會非常高,相對的容量就會很小,比如一般是幾百K到1M。AI?Core內(nèi)部的核心組件有三個計算單元,標量計算單元、向量計算單元,矩陣計算單元。另外還有一個DMA搬運單元,DMA搬運單元負責在Global Memory和Local Memory之間搬運數(shù)據(jù)。
AI Core內(nèi)部的異步并行計算過程:Scalar計算單元讀取指令序列,并把向量計算、矩陣計算、數(shù)據(jù)搬運指令發(fā)射給對應單元的指令隊列,向量計算單元、矩陣計算單元、數(shù)據(jù)搬運單元異步并行執(zhí)行接收到的指令。該過程可以參考上圖中藍色箭頭所示的指令流。不同的指令間有可能存在依賴關(guān)系,為了保證不同指令隊列間的指令按照正確的邏輯關(guān)系執(zhí)行,Scalar計算單元也會給對應單元下發(fā)同步指令。各單元之間的同步過程可以參考上圖中的橙色箭頭所示的同步信號流。
AI Core內(nèi)部數(shù)據(jù)處理的基本過程:DMA搬入單元把數(shù)據(jù)搬運到Local Memory,Vector/Cube計算單元完成數(shù)據(jù),并把計算結(jié)果寫回Local Memory,DMA搬出單元把處理好的數(shù)據(jù)搬運回Global Memory。該過程可以參考上圖中的紅色箭頭所示的數(shù)據(jù)流。
2?Ascend?C編程模型基礎(chǔ)
2.1?Ascend C編程范式
Ascend C編程范式是一種流水線式的編程范式,把算子核內(nèi)的處理程序,分成多個流水任務,通過隊列(Queue)完成任務間通信和同步,并通過統(tǒng)一的內(nèi)存管理模塊(Pipe)管理任務間通信內(nèi)存。流水編程范式應用了流水線并行計算方法。

若n=3,即待處理的數(shù)據(jù)被切分成3片,則上圖中的流水任務運行起來的示意圖如下,從運行圖中可以看出,對于同一片數(shù)據(jù),Stage1、Stage2、Stage3之間的處理具有依賴關(guān)系,需要串行處理;不同的數(shù)據(jù)切片,同一時間點,可以有多個任務在并行處理,由此達到任務并行、提升性能的目的。

Ascend C分別針對Vector、Cube編程設計了不同的流水任務。開發(fā)者只需要完成基本任務的代碼實現(xiàn)即可,底層的指令同步和并行調(diào)度由Ascend C框架實現(xiàn),開發(fā)者無需關(guān)注。
2.2?矢量編程范式
矢量編程范式把算子的實現(xiàn)流程分為3個基本任務:CopyIn,Compute,CopyOut。CopyIn負責搬入操作,Compute負責矢量計算操作,CopyOut負責搬出操作。

?我們只需要根據(jù)編程范式完成基本任務的代碼實現(xiàn)就可以了,底層的指令同步和并行調(diào)度由Ascend C框架來實現(xiàn)。
那Ascend C是怎么完成不同任務之間的數(shù)據(jù)通信和同步的呢?這里Ascend C提供了Queue隊列管理的API,主要就是兩個隊列操作API EnQue、DeQue以及內(nèi)存的邏輯抽象。
矢量編程中使用到的邏輯位置(QuePosition)定義如下:
搬入數(shù)據(jù)的存放位置:VECIN;
計算中間變量的位置:VECCALC;
搬出數(shù)據(jù)的存放位置:VECOUT。
從前面可以看到,矢量編程主要分為CopyIn、Compute、CopyOut三個任務。CopyIn任務中將輸入數(shù)據(jù)從Global內(nèi)存搬運至Local內(nèi)存后,需要使用EnQue將LocalTensor放入VECIN的Queue中;Compute任務等待VECIN的Queue中LocalTensor出隊之后才可以完成矢量計算,計算完成后使用EnQue將計算結(jié)果LocalTensor放入到VECOUT的Queue中;CopyOut任務等待VECOUT的Queue中LocalTensor出隊,再將其拷貝到Global內(nèi)存。這樣 ,Queue隊列就完成了三個任務間的數(shù)據(jù)通信和同步。具體流程和流程圖如下:
1.?Stage1:CopyIn任務。
使用DataCopy接口將GlobalTensor數(shù)據(jù)拷貝到LocalTensor。
使用EnQue接口將LocalTensor放入VECIN的Queue中。
2.?Stage2:Compute任務。
使用DeQue接口從VECIN中取出LocalTensor。
使用Ascend C接口完成矢量計算。
使用EnQue接口將計算結(jié)果LocalTensor放入到VECOUT的Queue中。
3.?Stage3:CopyOut任務。
使用DeQue接口從VECOUT的Queue中去除LocalTensor。
使用DataCopy接口將LocalTensor拷貝到GlobalTensor上。

這樣我們的kernel實現(xiàn)代碼就很清晰了。先初始化內(nèi)存和隊列,然后通過編程范式實現(xiàn)CopyIn、Compute、CopyOut三個Stage就可以了。
2.3?SPMD并行編程-多核
最前面介紹昇騰AI處理器的時候,有介紹過AI?Core是有多個的,那我們怎么把多個AI?Core充分利用起來呢?常用的并行計算方法中,有一種SPMD(Single-Program Multiple-Data)數(shù)據(jù)并行的方法,簡單說就是將數(shù)據(jù)分片,每片數(shù)據(jù)經(jīng)過完整的一個數(shù)據(jù)處理流程。這個就能和昇騰AI處理器的多核匹配上了,我們將數(shù)據(jù)分成多份,每份數(shù)據(jù)的處理運行在一個核上,這樣每份數(shù)據(jù)并行處理完成,整個數(shù)據(jù)也就處理完了。Ascend C是SPMD(Single-Program Multiple-Data)編程,多個AI Core共享相同的指令代碼,每個核上的運行實例唯一的區(qū)別是就是block_idx(內(nèi)置變量)不同,這樣我們就可以通過block_idx來區(qū)分不同的核,只要對Global Memory上的數(shù)據(jù)地址進行切分偏移,就可以讓每個核處理自己對應的那部分數(shù)據(jù)了。

算子被調(diào)用時,所有的計算核心都執(zhí)行相同的實現(xiàn)代碼,入口函數(shù)的入?yún)⒁彩窍嗤摹C總€核上處理的數(shù)據(jù)地址需要在起始地址上增加block_idx*BLOCK_LENGTH(每個block處理的數(shù)據(jù)長度)的偏移來獲取。這樣也就實現(xiàn)了多核并行計算的數(shù)據(jù)切分。
2.4Ascend C API介紹
在整個kernel實現(xiàn)中,最最核心的代碼就是Add(zLocal, xLocal, yLocal, TILE_LENGTH);通過一個Ascend C提供的API接口完成了所有數(shù)據(jù)的加法計算,對,沒看錯,就是這個接口完成了計算。
接下來就介紹下Ascend C提供的API。Ascend C算子采用標準C++語法和一組類庫API進行編程,類庫API主要包含以下幾種,大家可以在核函數(shù)的實現(xiàn)中根據(jù)自己的需求選擇合適的API:

計算類API,包括標量計算API、向量計算API、矩陣計算API,分別實現(xiàn)調(diào)用Scalar計算單元、Vector計算單元、Cube計算單元執(zhí)行計算的功能。
數(shù)據(jù)搬運API,上述計算API基于Local Memory數(shù)據(jù)進行計算,所以數(shù)據(jù)需要先從Global Memory搬運至Local Memory,再使用計算接口完成計算,最后從Local Memory搬出至Global Memory。執(zhí)行搬運過程的接口稱之為數(shù)據(jù)搬移接口,比如DataCopy接口。
內(nèi)存管理API,用于分配管理內(nèi)存,比如AllocTensor、FreeTensor接口。
任務同步API,完成任務間的通信和同步,比如EnQue、DeQue接口。
Ascend C API的計算操作數(shù)都是Tensor類型:GlobalTensor和LocalTensor。
介紹完Ascend C?API種類后,下面來解釋下為什么一個Add接口就可以計算所有的數(shù)。原來Ascend C編程模型是基于SIMD(單指令多數(shù)據(jù))架構(gòu)的,單條指令可以完成多個數(shù)據(jù)操作,同時在API內(nèi)部封裝了一些指令的高級功能。
2.5?算子執(zhí)行基本流程
前面有提到,在異構(gòu)計算架構(gòu)中,NPU與CPU是協(xié)同工作的,在Ascend?C編程模型中,我們需要實現(xiàn)NPU側(cè)的代碼和CPU側(cè)的代碼。在NPU側(cè)的代碼我們通常叫做Kernel實現(xiàn)代碼,CPU側(cè)的代碼我們一般叫做Host實現(xiàn)代碼,一份完整的Ascend C代碼,通常包括Host側(cè)實現(xiàn)代碼和Kernel側(cè)實現(xiàn)代碼。Ascend C算子執(zhí)行的基本流程如下:
1、?初始化Device設備;
2、?創(chuàng)建Context綁定設備;
3、?分配Host內(nèi)存,并進行數(shù)據(jù)初始化;
4、?分配Device內(nèi)存,并將數(shù)據(jù)從Host上拷貝到Device上;
5、?用內(nèi)核調(diào)用符<<<>>>調(diào)用核函數(shù)完成指定的運算;
6、?將Device上的運算結(jié)果拷貝回Host;
7、?釋放申請的資源。
2.6?核函數(shù)介紹
上面的流程中,最重要的一步就是調(diào)用核函數(shù)來進行并行計算任務。核函數(shù)(Kernel Function)是Ascend C算子Device側(cè)實現(xiàn)的入口。在核函數(shù)中,需要為在AI核上執(zhí)行的代碼規(guī)定要進行的數(shù)據(jù)訪問和計算操作。
上面這個是一個核函數(shù)聲明的示例,extern "C"表示核函數(shù)按照類C的編譯和連接規(guī)約來編譯和連接,__global__函數(shù)類型限定符表示它是一個核函數(shù),?__aicore__函數(shù)類型限定符表示該核函數(shù)在device側(cè)的AI Core上執(zhí)行。參數(shù)列表中的變量類型限定符__gm__,表明該指針變量指向Global Memory上某處內(nèi)存地址,注意這里的入?yún)⒅荒苤С种羔樆駽/C++內(nèi)置數(shù)據(jù)類型,樣例里指針使用的類型為uint8_t,在后續(xù)的使用中需要將其轉(zhuǎn)化為實際的指針類型。
Ascend C編程模型中的核函數(shù)采用內(nèi)核調(diào)用符<<<...>>>來調(diào)用,樣例如下:
kernel_name即為上面講的核函數(shù)名稱,argument list是核函數(shù)的函數(shù)入?yún)?,?lt;<<>>>中間,有3個參數(shù):
l?blockDim,規(guī)定了核函數(shù)將會在幾個核上執(zhí)行,我們可以先設置為1;
l?l2ctrl,保留參數(shù),暫時設置為固定值nullptr,我們不用關(guān)注;
l?stream,使用aclrtCreateStream創(chuàng)建,用于多線程調(diào)度。
3樣例開發(fā)講解
3.1?樣例代碼結(jié)構(gòu)
3.2主要文件
3.2.1輸入數(shù)據(jù)和真值數(shù)據(jù)生成腳本文件:KERNEL_NAME.py。
根據(jù)算子的輸入輸出編寫生成輸入數(shù)據(jù)和真值數(shù)據(jù)的腳本。
本例子生成8 * 200 * 1024大小的fp16數(shù)據(jù):
3.2.2?編譯工程文件:CMakeLists.txt
用于編譯cpu側(cè)或npu側(cè)運行的Ascend C算子。主要關(guān)注CMakeLists.txt中源文件是否全部列全。
3.2.3調(diào)用算子的應用程序:main.cpp
主要是內(nèi)存申請,數(shù)據(jù)拷貝和文件讀寫等操作,并最終調(diào)用算子,相關(guān)API的介紹如下:
1、?AscendCL初始化接口aclInit,用于運行時接口AscendCL的初始化,是程序最先調(diào)用的接口;aclrtCreateContext和aclrtCreateStream用于創(chuàng)建Context和Stream,主要用于線程相關(guān)的資源管理。?
2、?aclrtMallocHost接口,用于在Host上申請內(nèi)存:
aclError?aclrtMallocHost(void **hostPtr, size_t size)
這個函數(shù)和C語言中的malloc類似,用于在Host上申請一定字節(jié)大小的內(nèi)存,其中hostPtr是指向所分配內(nèi)存的指針,size是申請的內(nèi)存大小,如果需要釋放這塊內(nèi)存的話,使用aclrtFreeHost接口釋放,這和C語言中的free函數(shù)對應。
3、?aclrtMalloc接口,用于在Device上申請內(nèi)存:
和Host上的內(nèi)存申請接口相比,多了一個policy參數(shù),用于設置內(nèi)存分配規(guī)則,一般設置成ACL_MEM_MALLOC_HUGE_FIRST就可以了。使用完畢后可以用對應的aclrtFree接口釋放內(nèi)存。
4、?aclrtMemcpy接口,用于Host和Device之間數(shù)據(jù)拷貝:
前面申請的內(nèi)存區(qū)分了Host內(nèi)存和Device內(nèi)存,那就會涉及到數(shù)據(jù)同步的問題,aclrtMemcpy就是用于Host和Device之間數(shù)據(jù)通信的接口:
其中src指向數(shù)據(jù)源,而dst是目標內(nèi)存地址,destMax?是目的內(nèi)存地址的最大內(nèi)存長度,count是拷貝的字節(jié)數(shù),其中aclrtMemcpyKind控制復制的方向:ACL_MEMCPY_HOST_TO_HOST、ACL_MEMCPY_HOST_TO_DEVICE、ACL_MEMCPY_DEVICE_TO_HOST和ACL_MEMCPY_DEVICE_TO_DEVICE,像ACL_MEMCPY_HOST_TO_DEVICE就是將Host上數(shù)據(jù)拷貝到Device上。
5、?核心函數(shù)為CPU側(cè)的調(diào)用kernel函數(shù)
和NPU側(cè)調(diào)用的
完整代碼如下:
3.2.4一鍵式編譯運行腳本run.sh
編譯和運行應用程序。
cpu側(cè)運行命令:
npu側(cè)運行命令:
參數(shù)含義如下:
<kernel_name>表示需要運行的算子。
<soc_version>表示算子運行的AI處理器型號。
<core_type>表示在AI Core上或者Vector Core上運行,參數(shù)取值為AiCore/VectorCore。
<run_mode>表示算子以cpu模式或npu模式運行,參數(shù)取值為cpu/npu。
3.3?kernel實現(xiàn)
3.3.1?函數(shù)原型定義
本樣例中,函數(shù)名為leakyrelu_custom,根據(jù)對算子輸入輸出的分析,確定有2個參數(shù)x,y,其中x為輸入內(nèi)存,y為輸出內(nèi)存。核函數(shù)原型定義如下所示:
使用__global__函數(shù)類型限定符來標識它是一個核函數(shù),可以被<<<...>>>調(diào)用;使用__aicore__函數(shù)類型限定符來標識該核函數(shù)在設備端AI Core上執(zhí)行;為方便起見,統(tǒng)一使用GM_ADDR宏修飾入?yún)?,GM_ADDR宏定義:
3.3.2?獲取tilling數(shù)據(jù),并調(diào)用算子類的Init和Process函數(shù)。
算子類的Init函數(shù),完成內(nèi)存初始化相關(guān)工作,Process函數(shù)完成算子實現(xiàn)的核心邏輯。
3.3.3?對核函數(shù)的調(diào)用進行封裝
封裝后得到leakyrelu_custom_do函數(shù),便于主程序調(diào)用。#ifndef __CCE_KT_TEST__表示該封裝函數(shù)僅在編譯運行NPU側(cè)的算子時會用到,編譯運行CPU側(cè)的算子時,可以直接調(diào)用add_custom函數(shù)。調(diào)用核函數(shù)時,除了需要傳入輸入輸出參數(shù)x,y,切分相關(guān)參數(shù)tiling,還需要傳入blockDim(核函數(shù)執(zhí)行的核數(shù)), l2ctrl(保留參數(shù),設置為nullptr), stream(應用程序中維護異步操作執(zhí)行順序的stream)來規(guī)定核函數(shù)的執(zhí)行配置。
3.3.4獲取tiling參數(shù)
主要從tilingPointer中獲取tiling的參數(shù)totalLength(總長度)、tileNum(切分個數(shù),單核循環(huán)處理數(shù)據(jù)次數(shù))和scalar(LeakyRelu計算標量)。
3.3.5Init函數(shù)
主要獲取tiling數(shù)據(jù)后,設置單核上gm的地址和Buffer的初始化。
3.3.6?Process函數(shù)
主要實現(xiàn)三個CopyIn、Compute、CopyOut這三stage。
3.3.7 CopyIn函數(shù)
負責從Global Memory拷貝數(shù)據(jù)到Local Memory,并將數(shù)據(jù)加入Queue
3.3.8?Compute函數(shù)
負責從Queue中取出數(shù)據(jù),進行計算,并將結(jié)果放入Queue
3.3.9?CopyOut函數(shù)
負責從Queue中將數(shù)據(jù)取出,并將數(shù)據(jù)從Local Memory拷貝到Global Memory。
3.4?編譯和執(zhí)行
3.4.1?在CPU側(cè)執(zhí)行
執(zhí)行結(jié)果如下:

可以看到最后的輸出結(jié)果output_y.bin和標桿數(shù)據(jù)golden.bin的MD5值相同,說明計算結(jié)果相同。
執(zhí)行完成后,在input下存放輸入數(shù)據(jù)和tiling數(shù)據(jù),在output下面存放了輸出數(shù)據(jù)和標桿數(shù)據(jù),npuchk目錄下是每個核的npu_check執(zhí)行結(jié)果
在當前目錄還有一個可執(zhí)行二進制文件leakyrelu_custom_cpu,如果執(zhí)行報錯,可以通過gdb調(diào)試這個可執(zhí)行文件,具體調(diào)試可參考文末官方教程。
3.4.2?在NPU側(cè)執(zhí)行
在NPU側(cè)執(zhí)行有兩種方式:仿真執(zhí)行和上板運行,命令都相同,只是編譯選項不同,我們可以通過修改編譯選項-DASCEND_RUN_MODE為SIMULATOR運行CAModel仿真,設置為 ONBOARD是上板運行。
4參考資料
總之,學習Ascend?C,僅需了解C++編程、理解對列通信與內(nèi)存申請釋放機制、通過調(diào)用相應的計算接口與搬運接口,就可以寫出運行在昇騰AI處理器上的高性能算子。
了解更多Ascend C學習資源,請訪問官方教程:Ascend C編程指南(官方教程)
