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())进行访问。
5.2.2.2. __constant__
__constant__ 存储空间说明符(可与 __device__ 一起使用)声明一个变量,该变量:
- 驻留在常量存储空间中,
- 具有创建 MUSA 上下文的生存期,
- 每个设备具有不同的对象,
- 可从网格内的所有线程以及主机通过运行时库(
musaGetSymbolAddress()/musaGetSymbolSize()/musaMemcpyToSymbol()/musaMemcpyFromSymbol())进行访问。
5.2.2.3. __shared__
__shared__ 存储空间说明符(可与 __device__ 一起使用)声明一个变量,该变量:
- 驻留在线程块的共享内存空间中,
- 具有线程块的生命周期,
- 每个块具有不同的对象,
- 仅可从块内的所有线程访问,
- 没有固定地址。
将共享内存中的变量声明为外部数组时,例如
extern __shared__ float shared[];
数组的大小在启动时确定(请参阅执行配置)。以这种方式声明的所有 变量都从共享内存中的同一地址开始,因此数组中变量的布局必须通过偏移量进行显式管理。例如,如果想要在动态分配的共享内存中声明:
short array0[128];
float array1[64];
int array2[256];
可以通过以下方式声明和初始化数组:
extern __shared__ float array[];
__device__ void func() // __device__ or __global__ function
{
short* array0 = (short*)array;
float* array1 = (float*)&array0[128];
int* array2 = (int*)&array1[64];
}
请注意,指针需要与它们所指向的类型对齐,因此以下代码不起作用,例如,因为 array1 没有 4 个字节对齐。
extern __shared__ float array[];
__device__ void func() // __device__ or __global__ function
{
short* array0 = (short*)array;
float* array1 = (float*)&array0[127];
}
5.2.2.4. __restrict__
mcc 通过 __restrict__ 关键字支持受限指针。
C99 中引入了受限指针,以缓解 C 类型语言中存在的别名问题,并抑制从代码重新排序到公共子表达式消除的所有优化。
以下是一个涉及别名问题的示例,其中使用受限指针可以帮助编译器减少指令数量:
void foo(const float* a,
const float* b,
float* c)
{
c[0] = a[0] * b[0];
c[1] = a[0] * b[0];
c[2] = a[0] * b[0] * a[1];
c[3] = a[0] * a[1];
c[4] = a[0] * b[0];
c[5] = b[0];
...
}
在 C 类型语言中,指针 a、b 和 c 可能是别名,因此任何直写 c 都可以修改 a 或 b 的元素。这意味着,为了保证函数的正确性,编译器不能将 a[0] 和 b[0] 加载到寄存器中,将它们相乘,并将结果存储到 c[0] 和 c[1],因为 a[0] 与 c[0] 有可能实际上是同一个位置。因此编译器无法利用公共子表达式。同样,编译器不能将 c[4] 的计算重新排序为与 c[0] 和 c[1] 的计算附近,因为之前对 c[3] 的写入可能会改变对 c[4]计算的输入。
通过将 a、b 和 c 设置为受限指针,程序员向编译器断言指针实际上没有别名,这意味着通过 c 写入永远不会覆盖 a 或 b 的元素。这将改变函数原型,如下所示:
void foo(const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c);
请注意,需要对所有指针参数进行限制,编译器优化器才有可能获得好处。添加了 __restrict__ 关键字后,编译器现在可以随意重新排序和消除公共子表达式,同时保留与抽象执行模型相同的功能:
void foo(const float* __restrict__ a,
const float* __restrict__ b,
float* __restrict__ c)
{
float t0 = a[0];
float t1 = b[0];
float t2 = t0 * t1;
float t3 = a[1];
c[0] = t2;
c[1] = t2;
c[4] = t2;
c[2] = t2 * t3;
c[3] = t0 * t3;
c[5] = t1;
...
}
这里的效果是减少了内存访问次数和减少了计算次数。由于“缓存”的加载和常见的子表达式,寄存器压力的增加平衡了这一点。
由于寄存器压力是许多 MUSA 代码中的一个关键问题,由于占用率降低,使用受限指针可能会对 MUSA 代码的性能产生负面影响。
5.2.3. 内置变量类型
5.2.3.1. char, short, int, long, longlong, float, double
这些是从基本整数和浮点类型派生而来的向量类型。它们是结构体,并且可以分别通过字段 x、y、z 和 w 访问第一、第二、第三和第四分量。它们都带有一个形式为 `make_
` 的构造函数;例如
int2 make_int2(int x, int y);
创建类型为 int2 的向量,其值为 (x, y)。
下表详细介绍了矢量类型的对齐要求。
| 类型 | 对齐要求 |
|---|---|
| char1, uchar1 | 1 |
| char2, uchar2 | 2 |
| char3, uchar3 | 1 |
| char4, uchar4 | 4 |
| short1, ushort1 | 2 |
| short2, ushort2 | 4 |
| short3, ushort3 | 2 |
| short4, ushort4 | 8 |
| int1, uint1 | 4 |
| int2, uint2 | 8 |
| int3, uint3 | 4 |
| int4, uint4 | 16 |
| long1, ulong1 | 如果 sizeof(long) 等于 sizeof(int) 为 4 否则为 8 |
| long2, ulong2 | 如果 sizeof(long) 等于 sizeof(int) 为 8 否则为 16 |
| long3, ulong3 | 如果 sizeof(long) 等于 sizeof(int) 为 4 否则为 8 |
| long4, ulong4 | 16 |
| longlong1, ulonglong1 | 8 |
| longlong2, ulonglong2 | 16 |
| longlong3, ulonglong3 | 8 |
| longlong4, ulonglong4 | 16 |
| float1 | 4 |
| float2 | 8 |
| float3 | 4 |
| float4 | 16 |
| double1 | 8 |
| double2 | 16 |
| double3 | 8 |
| double4 | 16 |
5.2.3.2. dim3
此类型是基于 uint3 的整数向量类型,用于指定维度。当定义一个 dim3 类型的变量时,任何未指定的组件被初始化为 1。
5.2.4. 内置变量
内置变量指定网格和块尺寸以及块和线程索引。它们仅在设备上执行的函数中有效。
5.2.4.1. gridDim
此变量是 dim3 类型(参见 dim3)的,用于指定网格的尺寸。例如,如果网格的尺寸为 (x, y, z),则可以通过 gridDim.x、gridDim.y 和 gridDim.z 访问 x、y 和 z。
5.2.4.2. blockIdx
此变量是 uint3 类型(参见 char, short, int, long, longlong, float, double)的,用于指定网格中块的索引。
5.2.4.3. blockDim
此变量是 dim3 类型(参见 dim3)的,用于指定块的尺寸。
5.2.4.4. threadIdx
此变量是 uint3 类型(参见 char, short, int, long, longlong, float, double)的,用于指定块中线程的索引。
5.2.4.5. warpSize
此变量是 int 类型的,用于指定线程束的大小。
5.2.5. 同步函数
void __syncthreads();
在线程块中的所有线程都达到这一点,并且这些线程在 __syncthreads() 之前进行的所有全局和共享内存访问对块中的所有线程都可见之前等待。
__syncthreads() 用于协调同一块的线程之间的通信。当块中的某些线程访问共享内存或全局内存中的相同地址时,其中一些内存访问可能存在写后读、读后写或写后写风险。可以通过在这些访问之间同步线程来避免这些访存风险。
__syncthreads() 在条件代码中是允许的,但前提是条件在整个线程块中的计算方式相同,否则代码执行可能会挂起或产生意外的副作用。
__syncthreads() 支持下面描述的三种变体。
void __syncthreads_count(int predicate);
基本功能与 __syncthreads() 功能相同,附加功能是它会针对块的所有线程计算 predicate,并返回计算结果为非零的线程数。
int __syncthreads_and(int predicate);
基本功能与 __syncthreads() 功能相同,附加功能是它会针对块的所有线程计算 predicate,并当且仅当所有线程的计算结果都为非零时返回非零值。
int __syncthreads_or(int predicate);
基本功能与 __syncthreads() 功能相同,附加功能是它会针对块的所有线程计算 predicate,并当且仅当至少一个线程的计算结果为非零时返回非零值。
void __syncwarp(unsigned mask=0xffffffff);
将导致执 行线程等待,直到掩码中覆盖的同一线程束内所有线程都执行了一次 __syncwarp()(使用相同的掩码),然后再恢复执行。每个调用线程必须在掩码中设置自己的位,并且掩码中覆盖的所有未退出线程必须使用相同的掩码执行相应的操作,否则结果是未定义的。
执行 __syncwarp() 保证参与栅栏同步的线程之间的内存顺序。因此,线程束中希望通过内存进行通信的线程可以存储值到内存中,执行 __syncwarp() ,然后安全地读取同一线程束中其他线程存储的值。
5.2.6. 原子函数
原子函数对驻留在全局内存或共享内存中的一个 32 位或 64 位数据执行读-修改-写原子操作。例如,atomicAdd() 读取全局内存或共享内存中某个地址的一个字大小的值,向其增加一个数值,然后将结果写回同一地址。原子函数只能在设备函数中使用。
请注意,任何原子操作都可以基于 atomicCAS()(比较和交换)实现。例如,双精度浮点数操作可以按如下方式实现:
__device__ double atomicAdd(double* address, double val)
{
unsigned long long int* address_as_ull =
(unsigned long long int*)address;
unsigned long long int old = *address_as_ull, assumed;
do {
assumed = old;
old = atomicCAS(address_as_ull, assumed,
__double_as_longlong(val +
__longlong_as_double(assumed)));
// Note: uses integer comparison to avoid hang in case of NaN (since NaN != NaN)
} while (assumed != old);
return __longlong_as_double(old);
}
5.2.6.1 算术原子函数
5.2.6.1.1. atomicAdd()
int atomicAdd(int* address, int val);
unsigned int atomicAdd(unsigned int* address,
unsigned int val);
unsigned long long int atomicAdd(unsigned long long int* address,
unsigned long long int val);
float atomicAdd(float* address, float val);
double atomicAdd(double* address, double val);
读取位于全局或共享内存中地址 address 存储的 16 位、32 位或 64 位 old 值,计算 (old + val),并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.2. atomicSub()
int atomicSub(int* address, int val);
unsigned int atomicSub(unsigned int* address,
unsigned int val);
读取位于全局或共享内存中地址 address 存储的 32 位 old 值,计算 (old - val),并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.3. atomicExch()
int atomicExch(int* address, int val);
unsigned int atomicExch(unsigned int* address,
unsigned int val);
unsigned long long int atomicExch(unsigned long long int* address,
unsigned long long int val);
float atomicExch(float* address, float val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,将 val 存储到同一地址的内存中。这两个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.4. atomicMin()
int atomicMin(int* address, int val);
unsigned int atomicMin(unsigned int* address,
unsigned int val);
unsigned long long int atomicMin(unsigned long long int* address,
unsigned long long int val);
long long int atomicMin(long long int* address,
long long int val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,计算 old 和 val 中的最小值,并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.5. atomicMax()
int atomicMax(int* address, int val);
unsigned int atomicMax(unsigned int* address,
unsigned int val);
unsigned long long int atomicMax(unsigned long long int* address,
unsigned long long int val);
long long int atomicMax(long long int* address,
long long int val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,计算 old 和 val 中的最大值,并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.6. atomicInc()
unsigned int atomicInc(unsigned int* address,
unsigned int val);
读取位于全局或共享内存中地址 address 存储的 32 位 old 值,计算 ((old >= val) ? 0 : (old+1)),并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.7. atomicDec()
unsigned int atomicDec(unsigned int* address,
unsigned int val);
读取位于全局或共享内存中地址 address 存储的 32 位 old 值,计算 (((old == 0) || (old > val)) ? val : (old-1)),并将结果存储回同一地址的内存。这三个操作在一个原子事务中执行。该函数返回 old。
5.2.6.1.8. atomicCAS()
int atomicCAS(int* address, int compare, int val);
unsigned int atomicCAS(unsigned int* address,
unsigned int compare,
unsigned int val);
unsigned long long int atomicCAS(unsigned long long int* address,
unsigned long long int compare,
unsigned long long int val);
unsigned short int atomicCAS(unsigned short int *address,
unsigned short int compare,
unsigned short int val);
读取位于全局或共享内存中地址 address 存储的 16 位、32 位或 64 位 old 值,计算 (old == compare ? val : old),并将结果存储到同一地址的内存中。这两个操作在一个原子事务中执行。该函数返回 old(比较与交换)。
5.2.6.2. 位原子操作
5.2.6.2.1. atomicAnd()
int atomicAnd(int* address, int val);
unsigned int atomicAnd(unsigned int* address,
unsigned int val);
unsigned long long int atomicAnd(unsigned long long int* address,
unsigned long long int val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,计算 (old & val),并将结果存储回同一地址的内存。这两个操作在一个原子事务中执行。该函数返回 old。
5.2.6.2.2. atomicOr()
int atomicOr(int* address, int val);
unsigned int atomicOr(unsigned int* address,
unsigned int val);
unsigned long long int atomicOr(unsigned long long int* address,
unsigned long long int val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,计算 (old | val),并将结果存储回同一地址的内存。这两个操作在一个原子事务中执行。该函数返回 old。
5.2.6.2.3. atomicXor()
int atomicXor(int* address, int val);
unsigned int atomicXor(unsigned int* address,
unsigned int val);
unsigned long long int atomicXor(unsigned long long int* address,
unsigned long long int val);
读取位于全局或共享内存中地址 address 存储的 32 位或 64 位 old 值,计算 (old ^ val),并将结果存储回同一地址的内存。这两个操作在一个原子事务中执行。该函数返回 old。