5. MUSA编程基础
使用MUSA编程可以让用户编写程序完成任意的计算任务,这些计算任务中的一些步骤适合多线程并发执行,在GPU中运行可以获得巨大的加速收益。MUSA让用户能方便地编写出在GPU上运行的程序,MUSA语言是C++语言的扩展,其中绝大多数语法和c++一致,在c++语言的基础上添加了少许的新特性,如新的函数说明符,新的内建数据类型,以及调用gpu端函数的新的三括号语法等。本章将首先介绍使用这些新特性编写完整MUSA程序的整体思路和范式, 接着详细介绍这些拓展的特性。编写好的MUSA代码,需要通过mcc编译器编译成为mtgpu能识别的机器代码才能在GPU上执行,本章最后给出mcc编译器的使用方法。
5.1. MUSA基础编程
MUSA软件环境允许开发者使用C++作为高级编程语言进行开发。通过MUSA编程模型、编译器及相关API,MUSA为熟悉C++语言的用户提供了轻松简便的方法来编写执行在GPU设备上的程序。MUSA C++由相关的C++语言扩展和运行时库组成,核心的C++语言扩展在编程模型中引入,该扩展允许程序员将kernel定义为C++函数,并在调用kernel时使用扩展的语法来指定grid和block的维度。任何包含这些扩展的源文件都需要使用MUSA编译器进行编译。运行时库提供在主机上执行的C和C++ 函数,用于分配和释放Device内存、在Host内存和Device内存之间传输数据、管理具有多个Device的系统等。
5.1.1. MUSA编程模型
跟其他同类型的编程模型一样,MUSA编程模型是异构模型。异构意味着不同的体系结构,将程序划分为适用于不同体系结构的多个部分并执行在相应体系结构的硬件上,可以有效地提高程序的执行效率和性能;但这也意味着不同体系结构相互之间不能识别对方的二进制代码,需要在代码中做显式的区分。为了方便异构模型的描述和使用,需要将不同的体系结构进行抽象。在MUSA编程模型中,CPU和GPU两者分别被抽象为Host和Device,执行相应的指令;其次,MUSA编程模型假设Host和Device在各自的DRAM中维护独立的内存空间,相应的内存分别被称为Host Memory和Device Memory。所有的MUSA程序都需要Host和Device的协同工作,为了实现这一目的,典型的MUSA程序中既包含执行在CPU上的Host代码,也包含执行在GPU上的Device代码,两种代码协同工作,完成程序要求的任务。Host和Device之间依靠MUSA Runtime通信,管理内存的分配和释放、数据拷贝以及执行流控制等。MUSA编程模型中,Host一般只有一个,Device可以有一个或多个。典型的MUSA程序执行流程如下:
- 分配Host Memory,并进行数据初始化;
- 分配Device Memory,并将数据从Host Memory拷贝到Device Memory上;
- 调用MUSA的kernel函数,在Device上执行指定的任务;
- 将Device Memory中的结果拷贝回Host Memory;
- 释放Device和Host上分配的Memory。
5.1.2. kernel
以上流程中最重要的部分是调用MUSA的kernel函数来执行指定的任务,MUSA C++通过允许程序员定义被称为kernel的C++函数作为C++语法的扩展。kernel函数是MUSA中的重要概念,指的是在Host端调用、在Device端执行的函数;当用户调用kernel时,Device上会启动N个不同的MUSA线程并行执行N次,而不是跟普通的C++函数那样只执行一次。跟普通的C++函数相比,kernel函数需要用__global__符号声明,并且返回值类型必须为void;调用kernel时需要用<<
>>来指定kernel需要执行的block数量和线程数量划分,注意"<<<"和">>>"中间不能有空格。同一任务中的每个线程都执行相同的kernel函数,并且每个线程会被分配到唯一的线程ID,该ID值可以通过内置的变量来获得。kernel的调用方式如下所示。
kernel<<<gridSize, blockSize>>>(prams...);
作为说明,以下代码中会用到内置变量threadIdx,将两个大小为N的向量x和y相加,并将结果存储到向量z中。
// Kernel definition
__global__ void vecAdd(float* x, float* y, float* z) {
int i = threadIdx.x;
z[i] = x[i] + y[i];
}
int main() {
...
// Kernel invocation with N threads
vecAdd<<<1, N>>>(x, y, z);
...
}
在这个例子中,Device上的N个VecAdd()线程对向量x、y和z的每个元素都执行了一组加法,注意x、y和z三个指针都指向GPU内存中的数据。threadIdx的含义是线程在当前block中的索引。
5.1.3. 线程层次结构
kernel执行的block和线程数量划分是MUSA程序实现高效运行的关键之一,要理解这一点,需要对kernel的线程层次结构有清晰的认识。我们 知道,GPU上可以并行执行大量轻量级的线程,这是由GPU的硬件特性决定的,用户需要对这些线程进行层次化的组织,以充分利用硬件特性。
kernel在Device上执行时会启动若干个线程,单个kernel启动的所有线程被称为grid,同一个grid上的所有线程共享相同的全局内存空间,这是线程结构的第一层。而grid又可以分为一个或者多个thread block,每个block里面包含一个或多个线程,这些线程共享同一个MP内的资源,这是第二层。线程两层组织结构如下图所示,图中的gird和block均为2维的线程组织。

对于图1中的结构(水平方向为x轴),其中的grid和block大小如以下代码中的gridSize和blockSize变量所示,
dim3 gridSize(3, 2);
dim3 blockSize(5, 3);
kernel<<<gridSize, blockSize>>>(prams...);
其中,gridSize和blockSize分别表示该grid内block的数量划分和单个block内thread的数量划分,这两者都是定义为 dim3 类型的变量。 dim3 可以视为包含三个无符号整数(x,y,z)的结构体变量,在定义时缺省值初始化为1,因此grid和block可以灵活地定义为一维、二维或 三维结构。前面说到,每一个线程都会被分配唯一的线程ID。在上面的例子里,这个唯一的线程ID需要用两个内置变量blockIdx和threadIdx来唯一标识,它们都是 dim3 类型变量,其中blockIdx指明线程所属的block在grid中的索引,而threaIdx指明线程所在block中的索引,如图1中的Thread(3, 2)满足:
threadIdx.x = 3
threadIdx.y = 2
blockIdx.x = 1
blockIdx.y = 1
在这个例子里,Thread(1,1)的索引是第(1,1)个block内的第(3,2)个线程。在实际编程中,用户可以按需任意指定grid和block的数量,但需要注意这两者的数量是有限制的。在MUSA中,grid每个维度不能超过2147483647,grid中的每个block起码要有1个thread。同时,每个block的线程数量是有限制的,因为一个block的所有线程都需要驻留在同一个MP核心上,并且必须共享该MP核心的有限资源,比如register file和shared memory。在现有的GPU上,一个block最多可包含1024个线程。
通常来说,同一个问题可以有不同的grid/block大小配置(尽管在性能上可能会有一些差异)。一个具体的例子如下,以下代码将大小为MxN的两个矩阵A和B相加,并将结果存储到矩阵C中:
// Kernel definition
__global__ void MatAdd(float A[M][N], float B[M][N],
float C[M][N]) {
int i = threadIdx.x;
int j = threadIdx.y;
C[j][i] = A[j][i] + B[j][i];
}
int main() {
...
// Kernel invocation with one block of M * N * 1 threads
int numBlocks = 1;
dim3 threadsPerBlock(M, N);
MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
...
}
此处执行的是block数量为1的kernel,其中唯一的一个block里面有MxN个线程。当MxN的大小超过单个block允许的最大值时,这样的线程划分就无法执行了,需要考虑其他做法。首先,在上面的代码中block数量由实参numBlocks指定为1,但同一个kernel是可以由很多个block并行执行的,这些block有同样的大小。因此在上面这个例子里,我们可以把矩阵分为若干个相同大小的子块,每个子块由一个与子块同样大小的block执行。此外,我们还需要计算当前线程在所有线程中的位置。这个位置可以用到前文中的线程ID,也就是blockIdx和threadIdx,以及另一个内置变量blockDim,也就是每个block的尺寸来求得:因为矩阵每个子块的大小都是相同的,分别求x和y方向上每个线程对应的矩阵索引(j, i)即可,代码如下:
// Kernel definition
__global__ void MatAdd(float A[N][N], float B[N][N],
float C[N][N]) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
int j = blockIdx.y * blockDim.y + threadIdx.y;
if (i < N && j < N)
C[j][i] = A[j][i] + B[j][i];
}
int main() {
...
// Kernel invocation
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(N / threadsPerBlock.x, N / threadsPerBlock.y);
MatAdd<<<numBlocks, threadsPerBlock>>>(A, B, C);
...
}
这里每个block的大小为16x16(共256个线程),该大小可以任意指定(16x16是常见的划分)。上述例子中假设MxN矩阵的每个维度都可以由blcok中相同维度的线程数整除,但是在实际编程中更常见的情况是没法整除的,此时需要添加一些条件语句来判断是否属于可执行的thread,同时block数量要向上取整。所有block都是独立执行的,MUSA规定所有block可以以任何顺序执行(并行或串行)。这种独立性允许block以任意顺序在任意数量的内核上进行调度,从而使程序员能够编写随硬件核心数量扩展的代码。
同一block内的线 程可以通过shared memory来共享数据,并通过同步block内所有线程的执行来协调内存的访问,以此进行协作。更准确地说,可以通过调用内联函数__syncthreads()来指定kernel中的同步点;__syncthreads()可以充当屏障,这意味着同一block内的所有线程都必须在该同步点等待,直到所有线程都运行到这一点才能继续往前执行。为了高效协作和执行,上述shared memory应该是每个MUSA核心附近的低延迟内存,并且__syncthreads()本身应该是轻量级的。
5.1.4. 内存层次结构
前面说到同一block内的线程可以利用shared memory进行数据共享。包括shared memory,在MUSA中存在多个层次的内存空间,如图2所示。在MUSA线程执行期间,每个线程都可以从多个内存空间访问数据。首先,每个线程都有私有的register和local memory;其次,每个block都有对该block中所有线程可见的共享内存,并且与该block具有相同的生命周期。最后,所有线程都可以访问相同的global memory。
此外,MUSA中还存在两个只读的内存空间可供所有线程访问:constant memory和texture memory。global memory、constant memory和texture memory三个内存空间针对不同的内存使用进行了优化,texture memory还可以为某些特定数据格式提供不同的寻址模式以及数据过滤,在实际使用中可以根据需求选择不同内存空间来实现任务需求。内存结构涉及到程序优化,这里暂不深入探讨。global memory、constant memory和texture memory内存空间在同一应用程序启动的kernel中保持不变。

5.1.5. 获取GPU硬件配置
对于任何硬件,想要用其实现高效的程序,都需要对其硬件信息有一定的了解。在进行MUSA编程前,良好的实践应该是首先检查GPU的硬件配置信息,再开始针对性的编程工作,这样才能做到有的放矢。但是实现这一目的有个常见问题,对于GPU来说,不同代的产品在硬件配置上可能有很大的差异,有时候用户可能无法从外观上获取相关信息或者需要针对多代GPU产品进行适配,这会是一件麻烦事。好在MUSA中为获取硬件信息提供了相关的查询API,用户当前使用的GPU硬件信息可以通过下面的代码来获得:
void getMUSADeviceProp() {
int devID = 0;
musaDeviceProp devProp;
musaGetDeviceProperties(&devProp, devID);
std::cout << "Device Name: " << devProp.name << std::endl;
std::cout << "Device Warp Size: " << devProp.warpSize << std::endl;
std::cout << "Device Register Per Block: " << devProp.regsPerBlock << std::endl;
std::cout << "Device Register Per Multiprocessor: " << devProp.regsPerMultiprocessor << std::endl;
std::cout << "Device Shared Memory Per Block: " << devProp.sharedMemPerBlock << std::endl;
std::cout << "Device Shared Memory Per Multiprocessor: " << devProp.sharedMemPerMultiprocessor << std::endl;
std::cout << "Device Max Threads Per Block: " << devProp.maxThreadsPerBlock << std::endl;
std::cout << "Device Max Threads Per Multi Processor: " << devProp.maxThreadsPerMultiProcessor << std::endl;
std::cout << "Device Total Global Memory: " << devProp.totalGlobalMem << std::endl;
std::cout << "Device Total Const Memory: " << devProp.totalConstMem << std::endl;
std::cout << "Device Max Threads Dim: " << devProp.maxThreadsDim[0] << ", " << devProp.maxThreadsDim[1] << ", " << devProp.maxThreadsDim[2] << std::endl;
std::cout << "Device Max Grid Size: " << devProp.maxGridSize[0] << ", " << devProp.maxGridSize[1] << ", " << devProp.maxGridSize[2] << std::endl;
std::cout << "Device L2 Cache Size: " << devProp.l2CacheSize << std::endl;
return 0;
}
// 输出如下:
// Device Name: MTT S3000
// Device Warp Size: 128
// Device Register Per Block: 262144
// Device Register Per Multiprocessor: 131072
// Device Shared Memory Per Block: 28672
// Device Shared Memory Per Multiprocessor: 28672
// Device Max Threads Per Block: 1024
// Device Max Threads Per Multi Processor: 6144
// Device Total Global Memory: 34273656832
// Device Total Const Memory: 163840
// Device Max Threads Dim: 1024, 1024, 1024
// Device Max Grid Size: 2147483647, 2147483647, 2147483647
// Device L2 Cache Size: 524288
其中的devID用来指示是第几个设备,设备的ID从0开始递增。
5.1.6. MUSA编程实践
学习了上述MUSA编程基础相关的知识后,我们就可以开始一些简单的实践了。这里仍然用本节的第一个例子向量加法来举例,不同的是这里会考虑向量可能有任意长度。在开始之前,首先简单介绍下MUSA编程中内存管理相关的API。首先是在device上分配内存的musaMalloc函数。
__host__ __device__ musaError_t musaMalloc(void** devPtr, size_t size);
这个函数和C语言中的malloc十分类似,但是其操作对象是GPU的内存。musaMalloc可以在device上申请size字节的显存,其中devPtr是指向已分配内存的指针。从声明中可以看出,musaMalloc既可以在host端调用,也可以在device端调用。同样的,类似c中的free,要释放已分配的内存,应该使用musaFree函数。需要说明的是musaMalloc申请出来的显存,其内容不保证预先清零,与之相近行为的接口是muMemCreate。
__host__ __device__ musaError_t musaFree(void* devPtr);
另外一个重要的API是负责host和device之间数据传输的函数musaMemcpy:
__host__ musaError_t musaMemcpy(void* dst, const void* src, size_t count, musaMemcpyKind kind);
其中src是指向源数据的指针;dst则是指向目标数据的指针;count是需要传输的数据大小,单位是字节;最后一个参数kind是枚举变量,用于控制数据传输的方向,包括musaMemcpyHostToHost、musaMemcpyHostToDevice、musaMemcpyDeviceToHost和musaMemcpyDeviceToDevice,比如musaMemcpyHostToDevice意味着将host上的数据复制到device上。musaMemcpy只能在host端调用。
现在我们来实现该向量加法,目标是计算向量z = x + y。这里的grid和block都设为一维,首先定义kernel如下:
__global__ void vecAdd(const float* x, const float * y, float* z, int n) {
// get index
int index = threadIdx.x + blockIdx.x * blockDim.x;
// stride
int stride = blockDim.x * gridDim.x;
for (int i = index; i < n; i += stride)
{
z[i] = x[i] + y[i];
}
}
因为grid和block都是一维的,所以stride等于所有block的长度之和,即整个grid的线程数t。当向量长度n <= t时,代码中的循环只执行一次(或者不执行)。现在考虑向量长度n > t的情况,此时可以在每个线程中实现多个元素(元素总数/线程总数)的加法。这被称为grid-stride loop方法。当前n的数量很大时,我们可以将grid的线程数t设为刚好与硬件的MUSA核心数量相等,这样可以充分利用所有硬件核心的性能,代 价是某一部分核心在i < n时可能会“空跑”;这个例子里也可以将grid线程数量设为其他值,除了性能差别外不影响最后结果的一致性。以下是具体的代码实现:
#include <musa.h>
int main() {
int N = 1 << 20;
int nBytes = N * sizeof(float);
// host memory
float *x, *y, *z;
x = (float*)malloc(nBytes);
y = (float*)malloc(nBytes);
z = (float*)malloc(nBytes);
// initiate host data x and y
for (int i = 0; i < N; ++i) {
x[i] = i + 1.0f;
y[i] = i + 2.0f;
}
// device memory
float *d_x, *d_y, *d_z;
musaMalloc((void**)&d_x, nBytes);
musaMalloc((void**)&d_y, nBytes);
musaMalloc((void**)&d_z, nBytes);
// copy host data to device
musaMemcpy((void*)d_x, (void*)x, nBytes, musaMemcpyHostToDevice);
musaMemcpy((void*)d_y, (void*)y, nBytes, musaMemcpyHostToDevice);
// define grid and block layout
dim3 blockSize(256);
dim3 gridSize((N + blockSize.x - 1) / blockSize.x);
// execute kernel
vecAdd<<<gridSize, blockSize>>>(d_x, d_y, d_z, N);
// copy device data back to host
musaMemcpy((void*)z, (void*)d_z, nBytes, musaMemcpyDeviceToHost);
// free device memory
musaFree(d_x);
musaFree(d_y);
musaFree(d_z);
// free host memory
free(x);
free(y);
free(z);
return 0;
}
代码中向量的长度n设为1<<20,如果取block大小为256,那么grid的大小就是n/256=4096。此时kernel的线程层级结构如下图所示,对于其中的黄色方格所在的线程,其index是515。

需要注意的是kernel执行是与host异步的,这里使用musaMemcpy()函数会隐式地保证device和host同步之后再执行数据拷贝;这里也可以用显式的同步函数cudaDeviceSynchronize()。这样操作之后,下一步的代码才可以正确访问kernel计算的结果。
5.2. MUSA C++ API
5.2.1. 函数执行空间说明符
函数执行空间说明符表示函数是在主机上执行还是在设备上执行,以及它是可从主机调用还是可从设备调用。
5.2.1.1. global_
__global__ 执行空间说明符将函数声明为内核。这样的函数:
- 在设备上执行
- 可以从主机调用
__global__ 函数必须具有void返回类型,并且不能是类的成员。
对 __global__ 函数的任何调用都必须指定其执行配置。
对 __global__ 函数的调用是异步的,这意味着它在设备完成执行之前返回。
5.2.1.2. device
__device__ 执行空间说明符声明的函数:
- 在设备上执行
- 仅能从设备上调用
__global__ 和 __device__ 执行空间说明符不能一起使用。
5.2.1.3. host
__host__ 执行空间说明符声明的函数:
- 在主机上执行
- 仅能从主机上调用
只使用 __host__ 执行空间说明符来声明函数,等价于不使用 __host__、__device__ 或 __global__ 的任何一个执行空间描述符来声明函数;在这两种情况下,函数都只是为主机编译的。
__global__ 和 __host__ 执行空间说明符不能一起使用。
但是,__device__ 和 __host__ 执行空间说明符可以一起使用,在这种情况下,为主机和设备编译函数。应用程序兼容性中引入的 __MUSA_ARCH__ 宏可用于区分主机和设备之间的代码路径:
__host__ __device__ void func()
{
#if defined(__MUSA_ARCH__)
// device code path
#else
// host code path
#endif
}
5.2.1.4. 未定义的行为
“交叉执行空间”调用在以下情况下具有未定义的行为:
__MUSA_ARCH__ 有定义时从 __global__、__device__ 或 __host__ __device__ 函数内部对 __host__ 函数的调用。
__MUSA_ARCH__ 未定义时从 __host__ 函数内部对 __device__ 函数的调用。
5.2.1.5. noinline 和 forceinline
编译器在适当的时候内联任何 __device__ 函数。
如果可能的话,函数限定符 __noinline__ 可以用作编译器不内联函数的提示。
函数限定符 __forceinline__ 可用于强制编译器内联函数。
函数限定符 __noinline__ 和 __forceinline__ 不能一起使 用,并且这两个函数限定符都不能应用于内联函数。
5.2.2. 变量存储空间说明符
变量存储空间说明符表示变量在设备上的内存位置。
在设备代码中声明的一个自动变量,没有本节中描述的任何 __device__ 、__shared__ 和 __constant__ 存储空间说明符,通常驻留在寄存器中。然而,在某些情况下,编译器可能会选择将其放置在本地存储器中。
5.2.2.1. __device__
__device__ 存储空间说明符声明一个驻留在设备上的变量。
在接下来的三个部分中定义的其他存储空间说明符中,最多可以有一个与 __device__ 一起使用,以进一步表示变量属于哪个存储空间。如果它们都不存在,则变量:
- 位于全局存储空间中,
- 具有创建 MUSA 上下文的生存期,
- 每个设备具有不同的对象,
- 可从网格内的所有线程以及主机端通过运行时库(
musaGetSymbolAddress()/musaGetSymbolSize()/musaMemcpyToSymbol()/musaMemcpyFromSymbol())进行访问。