Cuda编程学习记录

发布时间 2023-08-29 17:53:54作者: HP0404

一. 基础知识

 

nvidia-smi指令:

 

nvidia-smi -q -i 0 #只显示0卡信息
nvidia-smi -q -i 0 -d MEMORY | tail -n 5 #只显示0卡内存信息
nvidia-smi -q -i 0 -d UTILIZATION | tail -n 4 #只显示0卡使用率
nvidia-smi -a -q -d CLOCK | fgrep -A 3 "Max Clocks" | fgrep "Memory" #检测设备的内存频率
#-d参数:
# MEMORY
# UTILIZATION
# ECC
# TEMPERATURE
# POWER
# CLOCK
# COMPUTE
# PIDS
# PERFORMANCE
# SUPPORTED_CLOCKS
# PAGE_RETIREMENT
# ACCOUNTING

#nvidia驱动程序将只使用ID为2和3的设备,并且会将设备ID分别映射为0和1
export CUDA_VISIBLE_DEVICES=2,3

 

 

Nsight 工具使用
//Nsight加注释
#include "nvToolsExt.h"
nvtxRangePushA(__FUNCTION__);
nvtxRangePop();

#nsight system性能分析
nsys profile -t cuda, osrt, nvtx, cudnn, cublas \ #需要追踪的API
    -y 60 \                                       #延迟分析(单位:秒)
    -d 20 \                                       #分析持续的区间(单位:秒)
    -o baseline \                                 #输出文件名
    -f true \                                     #是否覆盖源文件
    -w true \                                     #是否显示
    ./exe                                         #执行程序命令
#nvprof 性能分析,SM7.0以上用 ncu
nvporf --metrics branch_efficiency ./exe #检查内核线程束分化
nvprof --events branch,divergent_branch ./exe #获得分支和分化分支的事件计数器
nvprof --metrics achieved_occupancy ./exe #获得一个内核的可实现占用率:每周期内活跃线程束的平均数量与一个SM支持的线程束最大数量的比值
nvprof --metrics gld_throughput ./exe #检查内核的内存读取效率
nvprof --metrics gld_efficiency ./exe #检测全局加载效率,即被请求的全局加载吞吐量占所需的全局加载吞吐量的比值。它衡量了应用程序的加载操作利用设备内存带宽的程度
nvprof --devices 0 --metrics gld_transactions ./exe $OFFSET #显示全局加载事务数量,OFFSET去掉显示的是全局内存加载效率
nvprof --devices 0 --metrics gld_efficiency,gst_efficiency ./exe #获取全局加载效率和全局存储效率指标
 
1. 概念知识
运行cuda-10.1/samples/1_Utilities/deviceQuery/deviceQuery 可查看GPU信息
cuda核心=流处理器(SP)= 流多处理器(SM)个数 * 一个流多处理器中流处理器个数
2. cuda编程基本使用
const char* cudaGetErrorString ( cudaError_t error );
//cuda函数调用时,增加这个函数打印错误
#define CHECK(call) \
{ \
 const cudaError_t error = call; \
 if (error != cudaSuccess) \
 { \
 printf("Error: %s:%d, ", __FILE__, __LINE__); \
 printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \
 exit(1); \
 } \
}


//核函数定义
kernel_name <<<grid, block>>>(argument list);
__global__ void kernel_name(argument list);

//CUDA核函数的限制:
//·只能访问设备内存
//·必须具有void返回类型
//·不支持可变数量的参数
//·不支持静态变量
//·显示异步行为

//获取GPU显卡信息
cudaError_t cudaGetDeviceProperties(cudaDeviceProp* prop, int device);

const char* cudaGetErrorString ( cudaError_t error );
//cuda函数调用时,增加这个函数打印错误
#define CHECK(call) \
{ \
 const cudaError_t error = call; \
 if (error != cudaSuccess) \
 { \
 printf("Error: %s:%d, ", __FILE__, __LINE__); \
 printf("code:%d, reason: %s\n", error, cudaGetErrorString(error)); \
 exit(1); \
 } \
}
//核函数定义
kernel_name <<<grid, block>>>(argument list);
__global__ void kernel_name(argument list);
//CUDA核函数的限制:
//·只能访问设备内存
//·必须具有void返回类型
//·不支持可变数量的参数
//·不支持静态变量
//·显示异步行为
//获取GPU显卡信息
cudaError_t cudaGetDeviceProperties(cudaDeviceProp* prop, int device);

//CUDA编程的一个基本原则应是尽可能 地减少主机与设备之间的传输。

3. 显卡性能计算,以Tesla K10为例

单精度峰值浮点运算次数: 745 MHz核心频率*2 GPU/芯片*(8个多处理器*192个浮点单元*32核心/多处理器)*2 OPS/周期=4.58 TFLOPS(FLOPS表示每秒浮点运算次数)

内存带宽峰值:2 GPU/芯片*256位*2500 MHz内存时钟*2 DDR/8位/字节=320 GB/s

指令比:字节 : 4.58 TFLOPS/320 GB/s,也就是13.6个指令:1个字节

如果你的应用程序每访问一个字节所产生的指令数多于13.6,那 么你的应用程序受算法性能限制。大多数HPC工作负载受内存带宽的限制

 

 

 

二. 线程结构

    

  一个内核启动所产生的所有线程统称为一个网格(Grid),同一网格中的所有线程共享相同的全局内存空间;

       一个网格(Grid)由多个线程块(Block)构成,一个线程块(Block)包含一组线程(Thread);

       同一线程块(Block)内的线程(Thread)协作可以通过同步和共享内存来实现,不同块内的线程不能协作。

       blockIdx:线程块Block在网格Grid内的索引,blockIdx.x表示block的x坐标。

  threadIdx:线程块Block内的线程Thread索引,同理blockIdx。

  blockDim:线程块block维度,用每个线程块中的线程数来表示,对应的是threadIdx.

  gridDim:线程网格grid维度,用每个线程网格中的线程数来表示,对应的是blockIdx

一般会把grid组织成2D,block为3D。grid和block都使用dim3作为声明,例如:

  dim3 block(3);

       int grid_sizie = (nElem+block.x-1)/block.x; //[nElem/block.x] 向上取整

  dim3 grid(grid_sizie); 

dim3 block(dimx, dimy); 

dim3 grid((nx + block.x - 1) / block.x, (ny + block.y - 1) / block.y);

需要注意的是,dim3仅为host端可见,其对应的device端类型为uint3。

线程块和线程网格的划分方法:

由二维线程块构成的二维网格

    ix = threadIdx.x + blockIdx.x * blockDim.x 

    iy = threadIdx.y + blockIdx.y * blockDim.y

    idx = iy * nx + ix

 

 

 

由一维线程块构成的一维网格 

        ix = threadIdx.x + blockIdx.x * blockDim.x

        idx = ix

 

 

 

由一维线程块构成的二维网格

 

    ix = threadIdx.x + blockIdx.x * blockDim.x 

    iy = blockIdx.y

    idx = iy * nx + ix

 

线程块、线程束和线程网格之间的关系如下:
一个线程网格由多个线程块组成。
每个线程块由多个线程束组成。
每个线程束由一组连续的线程组成

三. GPU执行模型

 

3.1 理解线程块、线程束以及线程的本质
GPU中的每一个SM都能支持数百个线程并发执行,每个GPU通常有多个SM
当启动一个内核网格时,它的线程块被分布 在了可用的SM上来执行。
线程块一旦被调度到一个SM上,其中的线程只会在那个指定的 SM上并发执行。
多个线程块可能会被分配到同一个SM上,而且是根据SM资源的可用性进行调度的。
CUDA采用单指令多线程(SIMT)架构来管理和执行线程,每32个线程为一组,被称为线程束(warp),线程束是SM中基本的执行单元。
线程束中的所有线程同时执行相同的指令。
每个线程都有自己的指令地址计数器和寄存器状态,利用自身的数据执行当前的指令。
每个SM都将分配给它的 线程块划分到包含32个线程的线程束中,然后在可用的硬件资源上调度执行。
从逻辑角度来看,线程块是线程的集合,它们可以被组织为一维、二维或三维布局。 
从硬件角度来看,线程块是一维线程束的集合。在线程块中线程被组织成一维布局, 每32个连续线程组成一个线程束。
一个块的最内层维数(block.x)应该是线程束大小的倍数,能极大地提高了加载效率
一个线程块中线程束的数量=ceil(一个线程块的数量 / 线程束大小)
 网格和线程块大小的准则:
       ·保持每个块中线程数量是线程束大小(32)的倍数 
       ·避免块太小:每个块至少要有128或256个线程 
       ·根据内核资源的需求调整块大小
       ·块的数量要远远多于SM的数量,从而在设备中可以显示有足够的并行
       ·通过实验得到最佳执行配置和资源使用情况
3.2 线程束分化
    条件分支越多,并行性削弱越严重,线程束分化只发生在同一个线程束中,不同线程束中,不同条件值不会引起分化
3.3 同步
    同步级别:
        ·系统级:等待主机和设备完成所有的工作,cudaDeviceSynchronize
        ·块级:在设备执行过程中等待一个线程块中所有线程到达同一点,__syncthreads(void);
3.4. GPU线程束分化优化技巧:并行规约
     在向量中执行满足交换律和结合律的运算,被称为归约问题
a. 避免分支分化
b. 展开循环
c. 动态并行-嵌套执行

四. GPU内存模型

4.1.基本架构
 
一个核函数中的线程都有自己私有的本地内存。
一个线程块有自己的共享内存,对同 一线程块中所有线程都可见,其内容持续线程块的整个生命周期。
所有线程都可以访问全局内存。
所有线程都能访问的只读内存空间有:常量内存空间和纹理内存空间。
纹理内存为各种数据布局提供了不同的寻址模式和滤波模式
对于一个应用程序来说,全局内存、常量内存和纹理内存中的内容具有相同的生命周期
4.2. 内存分类:
不可编程内存:
            分为四类:一级缓存 、二级缓存 、只读常量缓存 、只读纹理缓存
            每个SM都有一个一级缓存,所有的SM共享一个二级缓存。一级和二级缓存都被用来存储本地内存和全局内存中的数据,也包括寄存器溢出的部分
            每个SM也有一个只读常量缓存和只读纹理缓存,它们用于在设备内存中提高来自于 各自内存空间内的读取性能
可编程内存的类型:
    1. 寄存器:
         寄存器是GPU上运行速度最快的内存空间。核函数中声明的一个没有其他修饰符的变量,通常存储在寄存器中,
         在核函数声明的数组中,如果用于引用该数组的索引是常量 且能在编译时确定,那么该数组也存储在寄存器中
   在核 函数中使用较少的寄存器将使在SM上有更多的常驻线程块。每个SM上并发线程块越多, 使用率和性能就越高
   如果一个核函数使用了超过硬件限制数量的寄存器,则会用本地内存替代多占用的寄存器,会给性能带来不利影响
   编译选项-Xptxas -v,-abi=no会检查核函数使用的硬件资源情况,输出寄存器的数量、共享内存的字节数以及每个线程所使用的常量内存的字节数
   __launch_bounds__(maxThreadsPerBlock, minBlocksPerMultiprocessor) 使用启发式策略最小化寄存 器的使用,以避免寄存器溢出
   编译选项-maxrregcount=32控制一个编译单元里所有核函数使用的寄 存器的最大数量
    2. 共享内存 :
        具有更高的带宽和更低的延迟,不要过度使用共享内存,否则将在不经意间限制活跃线程束的数量
        共享内存在核函数的范围内声明,其生命周期伴随着整个线程块, 是线程之间相互通信的基本方式
        在核函数中使用__shared__修饰符修饰的变量存放在共享内存中。extern __shared__ int tile[]; 
        使用void __sybcthreads()函数实现同步,该函数为线程块中的所有线程设置了一个执行障碍点,使得同一线程块中的所有线程必须都执行到该障碍点才能往下执行,这样就可以避免一些潜在的数据冲突。
//当__syncthreads被调用时,在同一个线程块中每个线程都必须等待直至该线程块中所有其他线程都已经达到这个同步点
__device__ void __syncthreads(void);
    3. 本地内存 :
        核函数中符合存储在寄存器中但不能进入被该核函数分配的寄存器空间中的变量将溢出到本地内存中,特点是高延迟和低带宽,存储在每个SM的一级缓存和每个设备的二级缓存中
        可能存放到本地内存中的变量有:
              ·在编译时使用未知索引引用的本地数组 
              ·可能会占用大量寄存器空间的较大本地结构体或数组 
              ·任何不满足核函数寄存器限定条件的变量
    4. 常量内存 :
        常量内存驻留在设备内存中,并在每个SM专用的常量缓存中缓存,使用__constant__修饰符修饰
        在全局空间内和所有内核函数之外进行静态声明,只可以申请64KB,对同一编译单元中的内核函数都是可见的
       当线程束中的所有线程从相同的内存地址中读取数据时,常量内存表现最好
       常量变量存储在常量内存中,内核函数只能从常量内存中读取数据,常量内存必须在host端代码中使用同步的cudaMemcpyToSymbol函数来进行初始化
cudaError_t cudaFuncSetCacheConfig(const void* func, enum cudaFuncCache cacheConfig);
//常量内存初始化 将count个字节从src指向的内存复制到symbol指向的内存中
cudaError_t cudaMemcpyToSymbol(const void* symbol, const void* src, size_t count);
//读取常量内存 从常量内存中拷贝数据
cudaError_t cudaMemcpyFromSymbol(void *dst, const void *symbol, size_t count, size_t offset = 0, cudaMemcpyKind kind = cudaMemcpyDeviceToHost);
    5. 纹理内存 :
        是一种通过指定的只读缓存访问的全局内存,纹理内存是对二维空间局部性的优化,所以线程束里使用纹理内存访问二维数据的线程可以达到最优性能,常用于做滤波和局部优化
    6. 全局内存:
        全局内存是GPU中最大、延迟最高并且最常使用的内存。全局指的是其作用域和生命周期。它的声明可以在任何SM设备上被访问到,并且贯穿应用程序的整个生命周期。
        全局内存常驻于设备内存中,可通过32字节、64字节或128字节的内存事务进行访问
        使用修饰符__device__可以静态声明或者使用cudaMalloc动态创建
//如何Malloc函数执行失败则返回cudaErrorMemoryAllocation。在已分配的全局内存中的值不会被清除,需要调用cudaMemset来初始化显存
cudaError cudaMalloc(void** devPtr, size_t size);
 //cudaMemcpyKind:
 //cudaMemcpyHostToHost       //CPU->CPU
 //cudaMemcpyHostToDevice     //CPU->GPU
 //cudaMemcpyDeviceToHost     //GPU->CPU
 //cudaMemcpyDeviceToDevice   //GPU->GPU
cudaError cudaMemcpy(void* dst, const void* src, size_t count, cudaMemcpyKind kind);
//异步数据拷贝
cudaError_t cudaMemcpyAsync(void* dst, const void* src, size_t count, cudaMemcpyKind kind, cudaStream_t stream = 0);
cudaError_t cudaMemset(void *devPtr, int value, size_t count);
cudaError_t cudaFree(void *devPtr);

/*静态声明一个全局变量并使用*/
__device__ float devData;
//初始化全局变量
cudaError_t cudaMemcpyToSymbol(devData, &value, sizeof(float));
//获取GPU全局变量地址
cudaError_t cudaGetSymbolAddress(void** devPtr, const void* symbol);
//拷贝全局变量到host端
cudaError_t cudaMemcpyFromSymbol(&value, devData, sizeof(float));
4.3 内存管理方式
 
1. 零拷贝内存:
    零拷贝内存是固定(不可分页)内存,该内存映射到设备地址空间中
    优点: ·当设备内存不足时可利用主机内存 ·避免主机和设备间的显式数据传输 ·提高PCIe传输率
    在进行频繁的读写操作时,使用零拷贝内存作为设备内存的补充将显著降低性能。因为每一次映射到内存的传输必须经过PCIe总线。与全局内存相比,延迟也显著增加
    如果你想共享主机和设备端的少量数据,可以使用零拷贝内存。对于由PCIe总线连接的离散GPU上的更大数据集来说,零拷贝内存不是一个好的选择,它会导致性能的显著下降
    主机和设备都可以访问的内存,使用cudaHostAlloc申请
//创建一个零拷贝内存, 用cudaFreeHost释放
    //flag:
    //cudaHostAllocDefalt:			使cudaHostAlloc函数的行为与cudaMallocHost函数一致
    //cudaHostAllocPortable:		返回能被所有CUDA上下文使用的固定内存,而不仅是执行内存分配的那一个
    //cudaHostAllocWriteCombined:	返回写结合内存,该内存可以在某些系统配置上通过PCIe总线上更快地传输,但是它在大多数主机上不能被有效地读取
    //cudaHostAllocMapped:			返回可以实现主机写入和设备读取被映射到设备地址空间中的主机内存
cudaError_t cudaHostAlloc(void **pHost, size_t count, unsigned int flags);
//获取映射到固定内存的device指针
cudaError_t cudaHostGetDevicePointer(void **pDevice, void *pHost, unsigned int flags);
2. 托管内存(统一内存寻址):
     统一内存中创建了一个托管内存池,内存池中已分配的空间可以用相同的内存 地址(即指针)在CPU和GPU上进行访问,托管内存就是其中一种
    可以被静态分配也可以被动态分配。可以通过添加__managed__注释,静态声明一个设备变量作为托管变量。但这个操作只能在文件范围和全局范围内进行。该变量可以从主机或设备代码中直接被引用
//托管内存,托管变量的声明
__device__ __managed__ int y;
//cuda6.0以后废弃,使用托管内存的程序可以利用自动数据传输和重复指针消除功能
cudaError_t cudaMallocManaged(void **devPtr, size_t size, unsigned int flags=0);
3. 固定内存:
     GPU不能在可分页的host端内存上安全地访问数据。
     cudaMallocHost/cudaFreeHost 申请和释放host端不分页的内存,可以直接拷贝到device端,用比可分页内存高得多的带宽进行读写
     当从可分页主机内存传输数据到设备内存时,CUDA驱动程序首先分配临时页面锁定的或固定的主机内存,将主机源数据复制到固定内存中,然后从固定内存传输数据给设备内存
     因为它减少了用于存储虚拟 内存数据的可分页内存的数量,所以分配过多的固定内存可能会降低主机系统的性能,
//分配固定主机内存
cudaError_t cudaMallocHost(void **devPtr, size_t count);
//释放固定主机内存
cudaError_t cudaFreeHost(void *ptr);
  4. 统一虚拟寻址 (UVA):使主机内存和设备 内存可以共享同一个虚拟地址空间
// allocate zero-copy memory at the host side
cudaHostAlloc((void **)&h_A, nBytes, cudaHostAllocMapped);
cudaHostAlloc((void **)&h_B, nBytes, cudaHostAllocMapped);
// initialize data at the host side
initialData(h_A, nElem);
initialData(h_B, nElem);
//不需要调用cudaHostGetDevicePointer
// invoke the kernel with zero-copy memory
sumArraysZeroCopy<<<grid, block>>>(h_A, h_B, d_C, nElem);
 
4.3 内存管理方式(没太搞懂,需要进一步加深理解!!!)
全局内存访问模式:
    1. 对齐与合并访问
            合并内存访问:当一个线程束中全部的32个线程访问一个连续的内存块
            对齐内存访问:对齐合并内存访问的理想状态是线程束从对齐内存地址开始访问一个连续的内存块
    2. 全局内存读取
           有缓存与没有缓存:如果启用一级缓存,则内存加载被缓存,缓存加载可以分为对齐/非对齐及合并/非合并
           对齐与非对齐:如果内存访问的第一个地址是32字节的倍数,则对齐加载 
           合并与未合并:如果线程束访问一个连续的数据块,则加载合并
           只读缓存:使用函数__ldg,将常量__restrict__修饰符应用到指针上
    3. 全局内存写入
    4. 结构体数组(AOS)与数组结构体(SOA):用SoA模式存储数据充分利用了GPU的内存带宽
    5. 性能调整方法: 
            目标:·对齐及合并内存访问,以减少带宽的浪费 ·足够的并发内存操作,以隐藏内存延迟
            方法:展开技术、增大并行性、最大化带宽利用率
            对齐内存访问要求所需的设备内存的第一个地址是32字节的倍数。合并内存访问指的是,通过线程束中的32个线程来 访问一个连续的内存块。

五. 流和并发

    1. cuda流
    流的分类:隐式声明的流(空流)和 显式声明的流(非空流) 异步流(非空流)和 同步流(空流/默认流),
    非空流被分为:·阻塞流 ·非阻塞流
    基于流的异步的内核启动和数据传输支持以下类型的粗粒度并发: 
        ·重叠主机计算和设备计算 
        ·重叠主机计算和主机与设备间的数据传输 
        ·重叠主机与设备间的数据传输和设备计算 
        ·并发设备计算
//Cuda Stream使用
cudaError_t cudaStreamCreate(cudaStream_t* pStream);
//当执行异步数据传输时,必须使用固定(或非分页的)主机内存
cudaError_t cudaMemcpyAsync(void* dst, const void* src, size_t count, cudaMemcpyKind kind, cudaStream_t stream = 0);
kernel_name<<<grid, block, sharedMemSize, stream>>>(argument list);
cudaError_t cudaStreamDestroy(cudaStream_t stream);
//强制阻塞主机,直到在给定流中所有的操作都完成
cudaError_t cudaStreamSynchronize(cudaStream_t stream);
//检查流中所有操作是否都已经完成
//当所有操作都完成时cudaStreamQuery函数会返回cudaSuccess
//当一个或多个操作仍在执行或等待执行时返回cudaErrorNotReady
cudaError_t cudaStreamQuery(cudaStream_t stream);
//创建带有优先级的流
//流优先级不会影响数据传输操作,只对计算内核有影响
cudaError_t cudaStreamCreateWithPriority(cudaStream_t* pStream, unsigned int flags, int priority);
//查询流可设置的优先级
//如果当前的设备不支持流优先级,cudaDeviceGetStreamPriorityRange将0返回给这两个参数
cudaError_t cudaDeviceGetStreamPriorityRange(int *leastPriority, int *greatestPriority);
//创建同步流和异步流
//flags:
//cudaStreamDefault: 默认流 (阻塞)
//cudaStreamNonBlocking: 异步流 (非阻塞),使得非空流对于空流的阻塞行为失效
cudaError_t cudaStreamCreateWithFlags(cudaStream_t* pStream, unsigned int flags);

//流回调
cudaError_t cudaStreamAddCallback(cudaStream_t stream, cudaStreamCallback_t callback, void *userData, unsigned int flags);

    2. cuda事件

    CUDA中事件本质上是CUDA流中的标记,它与该流内操作流中的特定点相关联,可以使用事件来执行以下两个基本任务: ·同步流的执行 ·监控设备的进展

    只有当一个给定CUDA流中先前的所有操作都执行结束后,记录在该流内的事件才会起作用

 

 

//cuda事件
// 事件在流执行中标记了一个点。它们可以用来检查正在执行的流操作是否已经到达了给定点
cudaEvent_t event;
//创建事件
cudaError_t cudaEventCreate(cudaEvent_t* event);
//销毁事件
cudaError_t cudaEventDestroy(cudaEvent_t event);
//一个事件排队进入CUDA流
cudaError_t cudaEventRecord(cudaEvent_t event, cudaStream_t stream = 0);
//等待一个事件并阻塞主机线程的调用
cudaError_t cudaEventSynchronize(cudaEvent_t event);
//检查事件中所有操作是否都已经完成
cudaError_t cudaEventQuery(cudaEvent_t event);
//计算事件启动和停止之间的运行时间,以毫秒为单位
cudaError_t cudaEventElapsedTime(float* ms, cudaEvent_t start, cudaEvent_t stop);
//
//flags:
//    cudaEventDefault
//    cudaEventBlockingSync  #指定使用cudaEventSynchronize函数同步事件会阻塞调用的线程
//    cudaEventDisableTiming #创建的事件只能用来进行同步,不需要记录时序数据
//    cudaEventInterprocess	 #创建的事件可能被用作进程间事件
cudaError_t cudaEventCreateWithFlags(cudaEvent_t* event, unsigned int flags);
 
    3. 同步
        cuda同步分为隐式同步和显式同步
        显式同步CUDA程序的几种方法: 同步设备 、同步流 、同步流中的事件、使用事件跨流同步
//cuda 同步
//强制主机端程序等待所有的核函数执行结束
cudaError_t cudaDeviceSynchronize(void);
//阻塞主机线程直到流中所有的操作完成
cudaError_t cudaStreamSynchronize(cudaStream_t stream);
cudaError_t cudaStreamQuery(cudaStream_t stream);
//阻塞主机线程直到等待一个事件的调用
cudaError_t cudaEventSynchronize(cudaEvent_t event);
cudaError_t cudaEventQuery(cudaEvent_t event);
//使指定的流等待指定的事件,该事件可能与同一个流相关,也可能与不同的流相关
cudaError_t cudaStreamWaitEvent(cudaStream_t stream, cudaEvent_t event);