CUDA C++ Extensions
阅读原文时间:2023年07月11日阅读:1

敲代码的时候总是会去CUDA官方文档中找找思路,感觉每次看英文文档都要耗费一点时间来翻译,干脆自己翻译一下便于以后查阅。官方文档:cuda-c-language-extensions

目录

CUDA函数修饰符主要包括__global____device____host__ ,每个修饰符指定不同的作用域。其中,__global__不能与另外两个修饰符共用,但后两者可以。

  1. __global__

__global__修饰符说明该函数为一个kernel,既该函数在设备端执行、在主机端调用(3.2及之后版本也可在设备端调用),在被调用时必须通过<<<>>>cudaLaunchDevice等方式指定执行配置。

__global__函数返回类型必须为void,且不能是一个类的成员。

__global__函数是一个异步函数。

  1. __device__

__device__ 修饰符说明该函数在设备端执行且只能在设备端调用。

  1. __host__

__host__ 修饰符说明该函数在主机端执行且只能在主机端调用。

__device____host__ 共用时,该函数会为该函数编译主机端和设备端两个版本,为防止主机端错误编译执行设备端的代码或在低版本设备中使用高版本特性,开发者可以通过__CUDA_ARCH__ 宏在编期确定代码。如:

__host__ __device__ func()
{
#if __CUDA_ARCH__ >= 700
   // Device code path for compute capability 7.x
#elif __CUDA_ARCH__ >= 600
   // Device code path for compute capability 6.x
#elif __CUDA_ARCH__ >= 500
   // Device code path for compute capability 5.x
#elif __CUDA_ARCH__ >= 300
   // Device code path for compute capability 3.x
#elif !defined(__CUDA_ARCH__)
   // Host code path
#endif
}
  1. inline

CUDA还提供了内联相关修饰符__noinline____forceinline__ 。默认情况下,编译器会在合适的时候将任意__device__ 函数内联,__noinline__ 提示(非强制)编译器尽可能不将该函数内联,而__forceinline__ 则强制编译器内联该函数。两修饰符不能共用。

CUDA变量内存修饰符主要包括__device____shared____constant__ ,这些修饰符用于指定变量内存所在。在没有修饰符的情况下,编译器会将变量存储在寄存器中(或通过register修饰符,非CUDA语法)。

  1. __device__

__device__修饰符声明该变量位于设备端内存区域,若变量仅由__device__修饰符修饰,则表明该变量:

  • 位于全局内存区域
  • 生命周期与CUDA上下文相同
  • 在每个设备中各自拥有一个不同的对象
  • 该变量可以被网格中的所有线程访问,或通过运行时函数(cudaGetSymbolAddresscudaGetSymbolSizecudaMemcpyToSymbolcudaMemcpyFromSymbol)在主机端访问。
  1. __constant__

__constant__ 修饰符可与__device__共用,修饰符说明该变量:

  • 位于常量内存区域
  • 生命周期与CUDA上下文相同
  • 在每个设备中各自拥有一个不同的对象
  • 该变量可以被网格中的所有线程访问,或通过运行时函数(cudaGetSymbolAddresscudaGetSymbolSizecudaMemcpyToSymbolcudaMemcpyFromSymbol)在主机端访问。
  1. __shared__

__shared__ 修饰符可与__device__共用,修饰符说明该变量:

  • 位于每个block的共享内存区域
  • 生命周期与block相同
  • 每个block各自拥有一个不同的对象
  • 只能被该block所属的所有线程访问
  • 变量地址非常量

__shared__ 可用于静态声明共享内存,也可动态声明共享内存,其中动态声明通过extern __shared__ float shared[];方式声明且在一个kernel中只能声明一次,并通过执行配置指定共享内存大小。

需要注意的是,通过动态声明共享内存的变量从内存的相同地址处开始,因此若变量类型不同,需要特别明确它们的偏移,例如如果开发者需要如下几个数组:

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]; //float类型需要4字节对齐
    int*   array2 =   (int*)&array1[64]; //int类型也需要4字节对齐
}

注意指针一定要根据类型对齐,否则程序将无法正常执行。

  1. __managed__

__managed__ 修饰符可与__device__共用,修饰符说明该变量:

  • 可在主机端和设备端直接引用,这也就意味着该变量可直接在主机端函数和设备端函数中进行读写。
  • 生命周期与应用相同
  1. __restrict__

CUDA中的__restrict__ 修饰符作用与C语言中restrict限定符作用相同,既它只可以用于限定和约束指针,并表明指针是访问一个数据对象的唯一且初始的方式,即它告诉编译器,所有修改该指针所指向内存中内容的操作都必须通过该指针来修改,而不能通过其它途径(其它变量或指针)来修改。

__restrict__ 修饰符能帮助编译器更好的优化代码,生成更有效率的汇编代码。如 int * __restrict__ ptrptr 指向的内存单元只能被 ptr 访问到,任何同样指向这个内存单元的其他指针都是未定义的,直白点就是无效指针。因此编译器可以通过重排序和通用子表达式消除等方式减少内存访问和数据计算的次数,但副作用是使用的寄存器数目会增加

kernel内置变量主要有gridDimdim3类型)、blockIdxuint3类型)、blockDimdim3类型)、threadIdxuint3类型)和warpSizeint类型)。其中dim3类型实际上也是uint3类型,不过在定义时xyz会初始化为1。

内置向量类型uint3实际是一个地址对齐的结构体,包含xyz三个元素。除uint3外常用的还包括float4(包含xyzw四个元素)。详情见https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#vector-types

CUDA假设设备为弱内存模型(weakly-ordered memory model),既GPU线程在向内存写数据时的顺序并不一定是GPU线程或主机线程观察到的顺序。

程序顺序:程序给出的指令的执行顺序,这代表程序员的意欲。

观察顺序:这是各个CPU看到的在内存中发生的顺序,注意这不是内存真的写入数据的顺序,而是所有的“观察者”看到的内存中发生更改的顺序。

举例来说,假如有两线程,它们分别执行如下函数:

__device__ volatile int X = 1, Y = 2;
__device__ void writeXY()
{
    X = 10;
    Y = 20;
}

__device__ void readXY()
{
    int A = X;
    int B = Y;
}

其中,线程1执行writeXY函数,线程2执行readXY函数,在强内存模式中,执行过后变量A、B的值只可能是如下3种情况:A=1且B=2,A=10且B=2,A=10且B=20,既A的赋值一定在B之前完成。而在弱内存模式中,变量A和B的赋值顺序不定,由设备决定,但若我们在两函数之间插入内存屏障函数,则一定能够保证A=10且B=20。

内存屏障函数可以保证设备在内存访问时的顺序,CUDA中主要有__threadfence_block__threadfence__threadfence_system三个函数,它们的主要区别在于作用范围不同。

void __threadfence_block();

__threadfence_block函数保证:

  • 该线程在__threadfence_block调用前发生的所有写操作对于调用后的该block中的所有线程而言是可见的。
  • 该线程在__threadfence_block调用前发生的所有读操作对于调用后的该线程而言是有序的。

注:为防止编译器优化,应该对操作指针使用volatile关键字。

void __threadfence();

__threadfence函数作用与__threadfence_block相同,但它作用于该设备中的所有线程。

void __threadfence_system();

__threadfence_system函数同样,但它作用于该程序下的所有设备(计算能力2.x及以上)、主机中的所有线程。

注意,内存屏障函数仅能保证该本线程下的内存访问是有序的,并不能保证对于其他线程是可见的(这由__syncthreads等同步函数保证)。

如下示例说明了如何在求长度为N的数组的和中利用内存屏障函数:

__device__ unsigned int count = 0;
__shared__ bool isLastBlockDone;
//为防止编译器将结果存储在cache中,result指针应使用volatile关键字
__global__ void sum(const float* array, unsigned int N,
                    volatile float* result)
{
    // 每个block计算数组的一部分,具体实现不列出
    float partialSum = calculatePartialSum(array, N);

    if (threadIdx.x == 0) {
        //每个block的0号线程将结果存储在result中
        result[blockIdx.x] = partialSum;

        //内存屏障函数保证接下来的原子操作一定在赋值操作之后发生
        __threadfence();

        //本block的结果确认已存储到result中,count自增1
          //atomicInc执行((count >= gridDim.x) ? 0 : (count+1))操作
        unsigned int value = atomicInc(&count, gridDim.x);

        //判断是否是最后一个block
        isLastBlockDone = (value == (gridDim.x - 1));
    }

    //同步确保所有线程都已正确得到自己的isLastBlockDone值
    __syncthreads();

    if (isLastBlockDone) {
        //最后一个block计算总和
        float totalSum = calculateTotalSum(result);

        if (threadIdx.x == 0) {
            //将总和存储在result中
            result[0] = totalSum;
            count = 0;
        }
    }
}

CUDA中的同步函数主要包括__syncthreads__syncthreads_count__syncthreads_and__syncthreads_or__syncwarp

void __syncthreads();

__syncthreads函数要求该block中的线程等待直到该block中的所有线程都到达该检查点且调用前的所有共享内存访问都已完成。__syncthreads函数主要用于同一block所有线程间的协同通信。__syncthreads支持条件语句但要求对于该block中的所有线程而言条件都为真或都为假,换句话说,__syncthreads要求该block中的所有线程都能执行到该位置。

int __syncthreads_count(int predicate);

__syncthreads_count函数会判断该block所有线程的predicate的值并返回predicate值非0的线程的数目。

int __syncthreads_and(int predicate);

__syncthreads_and函数当且仅当该block所有线程的predicate值非0时返回一个非0值。

int __syncthreads_or(int predicate);

__syncthreads_or函数当且仅当该block中任意一个线程的predicate值非0时返回一个非0值。

void __syncwarp(unsigned mask=0xffffffff);

__syncwarp函数同步mask中的所有线程,默认为同一warp中的所有线程。若需要同步warp中的第i个线程,则将mask的第i位设置为1。同样的,__syncwarp函数的所有参与线程都必须能执行到该位置,且所有参与线程的mask值必须相同。

__ldg、clock、__isGlobal

  1. __ldg函数

    T __ldg(const T* address);

__ldg函数从address中加载数据到只读cache中并返回该值。

  1. 时钟函数

clock函数(clock_t clock())和clock64函数(long long int clock64())返回设备的clock值。

  1. 地址判断函数

判断指针指向数据位于设备那个区域:

unsigned int __isGlobal(const void *ptr)
unsigned int __isShared(const void *ptr)
unsigned int __isConstant(const void *ptr)
unsigned int __isLocal(const void *ptr)

洗牌指令

__shfl_sync系列指令(俗称洗牌指令)用于在线程束中获取指定线程的变量值,该操作会在mask(一般取0xffffffff,每个bit位代表一个线程id)指定的那些线程中同时执行(同一mask中的线程必须执行相同指令),每次移动4字节或8字节的数据,但若指定线程为非活跃线程,则结果未知。具体功能如下:

T __shfl_sync(unsigned mask, T var, int srcLane, int width=warpSize);

__shfl_sync指令返回索引为srcLane线程的var变量值,其中srcLane大小为[0,width),类似的,width的值必须是2的幂数且不大于32。

T __shfl_up_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);

__shfl_up_sync指令返回索引为当前线程索引减去delta的值的线程的var值,若减去后的值小于0则不做任何操作(保持不变)。

T __shfl_down_sync(unsigned mask, T var, unsigned int delta, int width=warpSize);

__shfl_down_sync指令返回索引为当前线程索引加上delta的值的线程的var值,若加后的值大于width则不做任何操作(保持不变)。

T __shfl_xor_sync(unsigned mask, T var, int laneMask, int width=warpSize);

__shfl_xor_sync指令返回索引为当前线程索引按位异或laneMask后的值的线程的var值。注意若width值小于warpSize值,此时后面的线程可以访问前面的线程组的值(获取成功),但前面的线程不能访问后面线程组的值(保持不变)。

投票指令

__any_sync系列指令(俗称投票指令)对线程束中的参与线程(同样由mask指定)比较预测值predicate是否非零,并向所有参与的活跃线程广播比较结果:

int __all_sync(unsigned mask, int predicate);

当线程束中所有参与线程的预测值predicate非零时返回一个非零值。

int __any_sync(unsigned mask, int predicate);

当线程束中存在任意一个参与线程的预测值predicate非零时返回一个非零值。

unsigned __ballot_sync(unsigned mask, int predicate);

若线程束中的第N个线程活跃且其预测值predicate非零时,设定返回值的第N个bit为1,否则为0。

unsigned __activemask();

返回线程束内活跃线程组成的掩码。若线程束中的第N个线程为活跃线程,则设定第N个bit为1,否则为0(注意已退出线程也是非活跃线程)。该指令不执行同步。

匹配指令

__match_any_sync系列指令(俗称匹配指令)对线程束的参与线程(同样由mask指定)比较value值,并向所有参与线程广播比较结果:

unsigned int __match_any_sync(unsigned mask, T value);

返回value值相同的那些线程组成的掩码。

unsigned int __match_all_sync(unsigned mask, T value, int *pred);

返回mask值若所有参与线程的value值都相同,否则返回0。此外前者的预测值pred还将被设定为true,否则为false。

warp矩阵运算(wmma)利用Tensor Cores来加速D=A*B+C形式的矩阵乘加运算,在计算能力7.0及以上版本中,还支持混合精度运算(如int8、half等)。

wmma函数及类型都位于nvcuda::wmma命名空间中,此外,还有一些额外的如亚字节(Sub-byte)类型等实验性功能则位于nvcuda::wmma::experimental命名空间中,这些实验性功能不能保证会兼容后续版本。

正式性功能

template<typename Use, int m, int n, int k, typename T, typename Layout=void> class fragment;

void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm);
void load_matrix_sync(fragment<...> &a, const T* mptr, unsigned ldm, layout_t layout);
void store_matrix_sync(T* mptr, const fragment<...> &a, unsigned ldm, layout_t layout);
void fill_fragment(fragment<...> &a, const T& v);
void mma_sync(fragment<...> &d, const fragment<...> &a, const fragment<...> &b, const fragment<...> &c, bool satf=false);

以下是相关参数和函数解释:

fragment

fragment类型是一个重载类,该类将矩阵的一部分映射到warp的所有线程中。在不同架构下,将矩阵元素映射到fragment内部存储空间的实现是有所不同的,因此,开发者应当在同一架构下编译链接warp矩阵运算相关代码,否则极有可能会出现莫名其妙的bug(而且这些bug很难在编译或运行期间追踪到)。

fragment类第一个模版参数Use用于指定该矩阵参与矩阵乘加运算D=A*B+C的哪个位置,其中:

  • matrix_a 意味着该矩阵为矩阵A
  • matrix_b 意味着该矩阵为矩阵B
  • accumulator 意味着该矩阵为矩阵CDCD可以是同一个)

fragment类第二三四个数值型模版参数m,n,k指定了每个warp参与计算的矩阵大小(warp-wide matrix tiles),其中矩阵A的tiles大小为m * k,矩阵B的tiles大小为k * n,矩阵CD的tiles大小为m * n

fragment类第五个类型参数T目前支持矩阵AB的类型为__half, char, unsigned char,而矩阵CD的类型支持__half, float, int

fragment类第六个类型参数Layout用于指定矩阵的布局方式(行主序或列主序),其中矩阵CDLayout应该为默认值void,在加载或存储时指定,矩阵AB则根据实际情况选择行主序row_major 或者列主序col_major

fragment类的模版参数并不能随意设置,具体可选择参数可以参考官方文档https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#wmma-type-sizes。

load_matrix_sync

load_matrix_sync函数用于将内存中的矩阵加载到fragment中,显而易见,该函数将先同步所有warp。

在调用load_matrix_sync函数时,其参数必须得满足几个条件:

  • 指针mptr必须得256bit对齐(既32字节对齐)

  • 步长ldm(对于行主序,取连续两行间的元素数目;对于列主序,取连续两列间的元素数目)必须得是16字节的倍数(既对于__half类型至少为8,对于float 至少为4)

  • 由于矩阵AB的布局可以从fragment参数中推断出,因此对于矩阵CD,必须指定layout参数为行主序mem_row_major 或列主序mem_col_major

  • 该函数必须被warp中的所有线程调用,且函数参数和fragment模版参数必须一致。

store_matrix_sync

store_matrix_sync函数用于将fragment中的矩阵存储到内存中,显而易见,该函数将先同步所有warp。

类似的,在调用store_matrix_sync函数时,其参数必须得满足几个条件:

  • 指针mptr必须得256bit对齐(既32字节对齐)
  • 步长ldm必须得是16字节的倍数
  • 对于阵CD必须指定layout参数为mem_row_majormem_col_major
  • 该函数必须被warp中的所有线程调用,且函数参数和fragment模版参数必须一致。

由于fragment中矩阵元素的映射方式是未定的,因此开发者应该通过store_matrix_sync将矩阵存储到内存后再对矩阵进行进一步处理。但若仅是需要对矩阵中的每个元素进行统一的处理,也可以通过fragment直接访问,同样的,这种操作也需要warp中的所有线程参与且行为必须一致。

enum fragment<Use, m, n, k, T, Layout>::num_elements;
T fragment<Use, m, n, k, T, Layout>::x[num_elements];
wmma::fragment<wmma::accumulator, 16, 16, 16, float> frag;
float alpha = 0.5f; // Same value for all threads in warp
...
for(int t=0; t<frag.num_elements; t++)
    frag.x[t] *= alpha;

fill_fragment

fill_fragment函数将fragment中的矩阵元素都填充为值v,该函数必须被warp中的所有线程调用。

mma_sync

mma_sync函数将执行warp级同步的矩阵乘加运算D=A*B+C,显而易见,该函数将先同步所有warp。

mma_sync支持C=A*B+C,既矩阵CD相同。

除了要求warp中所有线程都得调用,且函数参数和fragment模版参数必须一致之外,调用mma_sync还需要注意m,n,k的值必须满足A=m*k,B=k*n,C=m*n,D=m*n的要求。

satf参数用于防止数值溢出,但注意该参数对于float类型已废弃,因此对于float类型satf参数应设为flase。

实验性功能

wmma的实验性功能主要为低精度类型的子字节运算,这些函数和结构都位于nvcuda::wmma::experimental命名空间中:

namespace experimental {
    namespace precision {
        struct u4; // 4-bit unsigned
        struct s4; // 4-bit signed
        struct b1; // 1-bit
     }
    enum bmmaBitOp { bmmaBitOpXOR = 1 };
    enum bmmaAccumulateOp { bmmaAccumulateOpPOPC = 1 };
}

fragment中,这些低精度类型分别映射到如下类型:

experimental::precision::u4 -> unsigned (8 elements in 1 storage element)
experimental::precision::s4 -> int (8 elements in 1 storage element)
experimental::precision::b1 -> unsigned (32 elements in 1 storage element)
all other types T -> T

需要注意的是,在子字节运算中矩阵A总是行主序的,而矩阵B总是列主序的,既在 fragment中,matrix_a总是row_majormatrix_b总是col_major

bmma_sync

bmma_sync函数将执行warp级同步的bit位矩阵元素计算D = (A op B) + C,目前op仅支持两类运算:首先是逻辑运算bmmaBitOp ,其次是累加运算bmmaAccumulateOp,而逻辑运算又仅支持128bit的异或运算bmmaBitOpXOR(矩阵A的一行128bit元素异或矩阵B的一列128bit元素),累加运算仅支持统计非0bit位的数目bmmaAccumulateOpPOPC

示例

#include <mma.h>

using namespace nvcuda;

__global__ void wmma_ker(half *a, half *b, float *c) {
   // Declare the fragments
   wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::col_major> a_frag;
   wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b_frag;
   wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;

   // Initialize the output to zero
   wmma::fill_fragment(c_frag, 0.0f);

   // Load the inputs
   wmma::load_matrix_sync(a_frag, a, 16);
   wmma::load_matrix_sync(b_frag, b, 16);

   // Perform the matrix multiplication
   wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);

   // Store the output
   wmma::store_matrix_sync(c, c_frag, 16, wmma::mem_row_major);
}

在计算能力2.x及以上设备上,CUDA支持kernel内的动态内存分配,函数声明如下:

__host__ __device__ void* malloc(size_t size);
__device__ void *__nv_aligned_device_malloc(size_t size, size_t align);
__host__ __device__  void free(void* ptr);
_host__ __device__ void* memcpy(void* dest, const void* src, size_t size);
__host__ __device__ void* memset(void* ptr, int value, size_t size);

值得注意的是,malloc分配的内存地址一定是16字节对齐的,但若你需要更多字节的对齐,可以利用__nv_aligned_device_malloc函数,该函数保证分配的内存地址一定是align的倍数,但要求align必须得是2的幂数,且只允许在设备端调用。若分配失败,malloc会返回NULL,同时cudaGetLastError会得到CUDA_ERROR_SHARED_OBJECT_INIT_FAILED 返回。

不同于cudaMalloc分配的是全局内存空间,malloc函数是分配的堆上的空间。设备端堆的默认大小为固定的8MB,但开发者可以通过cudaDeviceGetLimitcudaDeviceSetLimit进行设置。需要注意的是,由于堆上的动态内存分配实际上是发生在模块被加载到上下文时,因此在模块被加载后,堆的大小不能修改且不会根据需要自动增加,换句话说,堆大小的改动必须在所有程序的malloc函数发生之前。

与主机端类似,通过malloc分配的内存拥有和上下文同样的生命周期,除非通过free函数显式释放。这也就意味着通过kernel动态分配的内存同样可以被后续的所有kernel使用,除非显式free

free函数可以接受NULL指针但不允许重复释放同一内存。

通过malloc分配的内存不可以通过CUDA运行时释放(比如cudaFree),也不能用于任意一个CUDA运行时API或驱动API(如cudaMemcpy)。同样的,通过CUDA运行时分配的内存(比如cudaMalloc)同样不可以通过free释放。

为了尽可能的提高性能,开发者往往希望在SM中能常驻尽可能多的block和线程,默认情况下,编译器会通过减少寄存器和指令数目的方式来最小化寄存器的数目从而达到该目前,但开发者也可以通过在__global__函数定义前加__launch_bounds__()限定符的方式来为编译器优化提供更多的信息。

__global__ void
__launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor)
MyKernel(...)
{
    ...
}

其中:

  • maxThreadsPerBlock指定了程序在启动MyKernel时每个block的最大线程数,它被编译为.maxntid PTX指令
  • minBlocksPerMultiprocessor是一个可选项,它指定了每个SM的最小常驻block数目(desired,不一定能实现),它被编译为.minnctapersmPTX指令

当开发者指定了launch bounds时,编译器首先会计算出在满足每个blockmaxThreadsPerBlock个线程且至少有minBlocksPerMultiprocessorblock常驻SM的情况下,每个kernel可使用的寄存器数目的上限值L,接着编译器会进行如下操作:

  • 若初始的寄存器数目高于L,则编译器将会通过各种方式减少寄存器数目直到小于等于L,通常是通过使用本地内存或增加指令数目的方式
  • 若初始寄存器数目小于L
    • 若仅指定了maxThreadsPerBlock限定符,则编译器通过该值计算出寄存器数目的阈值:n个block常驻SM需要的寄存器数目~n+1个block常驻SM需要的寄存器数目,最后编译器继续使用无launch bounds时的优化策略进行抉择
    • 若两个限定符都指定了,则编译器会尽可能的提高寄存器的使用量以减少指令数,同时更好的隐藏单线程的指令延迟

注意当每个block的线程数大于maxThreadsPerBlock时,kernel将会启动失败。另外,在不同的架构下,最优的launch bounds往往是不同的,因此最好根据__CUDA_ARCH__设置不同的值(注意host端编译时__CUDA_ARCH__未定义)。

除了launch bounds外,开发者还可以通过maxrregcount 编译选项来为所有__global__函数指定寄存器使用量(指定了 launch bounds时会被launch bounds覆盖)。