一、异步编程
异步编程无论是在CPU编程还是在设计亦或是在多线程中都分析的太多了,但是为了把CUDA中的相关异步部分分析清楚,还是需要简单的赘述一下。所谓异步就是大家各搞各的互不关心,只是在达到一个特定的状态后会发一个通知。至于发了通知会怎么样,除了想要这个状态结果的线程会关心,发通知的异步任务是不Care的。
明白了什么是异步编程,就知道这种编程很爽又很麻烦。因为,异步可以不用考虑太多的复杂的同步机制,但实现难度上蓦然上了几个台阶。
二、异步屏障
虽然强调说异步编程更注重的是各管各家,但实际的应用场景下仍然存在着大量的需要异步任务也需要同步的情况。举一个简单的例子,有一个异步数据处理线程在为后面的操作准备数据,但它同时需要另外几个异步任务的相关处理结果。当它处理完成数据后,就可以声明一个完成的状态,然后去作另外的任务。当它需要其它异步线程的结果时,再去查询(wait),如果其它几个异步线程未完成相关工作,则进行等待。此时,就需要一个异步线程中实现类似同步的机制,而在CUDA中则提供了这么一种机制即异步屏障------cuda::barrier。
其实有过并行编程经验的开发者会笑的,这和CPU的内存屏障看来应该类似。从功能上看,确实有些类似,但在实际的应用上,异步屏蔽和常见的CPU中的同步屏障还是有区别的。
三、同步屏障和异步屏障
或许有人就发现问题了,CUDA中同步屏障如__syncthreads()和刚刚提到的异步屏障有什么不同呢?其实仔细看上面的异步屏障的说明就可以大致明白其关键点了。主要的区别有以下几个:
- 同步屏障强调的是同步,即各个线程必须同时到达。而异步屏蔽则不必如此,它可以异步的到达,需要是再等待。这对CUDA这种并行编程非常重要
- 同步屏障一旦进入,在未达到状态点时,所有的线程都是阻塞的。而异步线程则可以继续执行
- 同步屏障一般是受限于线程块内的线程,范围有明确的界限。而异步屏障则没有这个要求
- 二者的目的也有不同,同步屏障更强调的是线程块内的数据竞态安全;而异步屏障则更强调任务的协作
- 异步编程的粒度更小,更容易实现数据计算与任务执行的重叠进行
四、CUDA中的异步屏障的分析
CUDA中的异步屏障在使用前需要利用cuda::barrier::init()来进行初始化。它的生命周期(有的翻译成相位,相位更专业,但不好理解)分为四个阶段:
- 到达
参与异步屏障的相关线程会调用屏障的arrive(),表示自己到达了同步点。同时将屏障的倒计时计数器原子的减1,同时返回一个令牌(Token)。但线程本身不会阻塞可继续执行。但需要注意的时,调用arrive()时,倒计时数必须非零 - 倒计时
倒计时是指参参与屏障的线程的一种控制,它初始化值一般为参与线程的数量。它只减(原子操作,线程安全)不增,直到归零 - 完成
当所有的参与线程都arrive()后,即倒计时数归零后,所有的线程都到达了同步点。调用wait()函数的相关线程会立刻解除阻塞。其后,当前使用的屏障自动进入重置 - 重置
就是将倒计时数重新设置为预期的数量(初始化值),注意必须是当前阶段。然后将当前屏障的奇偶性进行翻转(表示进入了下一个重用的屏障阶段)。重置不需要显式的调用Reset函数
简单描述一下Token,它被返回时,内部包含了创建时的屏障的阶段的令牌。如果调用bar.wait(std::move(token)),则屏障会检查阶段的匹配性,相同则阻塞,直至计数器归零,否则立即返回,不阻塞。
五、异步屏障的特点
通过上面的说明可以发现,CUDA中异步屏障有着很显著的特点,主要包括:
- 并行控制的颗粒度更小,适合于更灵活的异步并行任务的协作
- 屏障的异步性意味着非强制同步,提高了并行性
- 异步性也可以实现数据和任务的重叠,更大可能的利用了硬件的效率实现优化的目的
六、应用
下面看一个简单的例子:
c
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
#include <cstdlib>
#include <cuda/barrier>
using barrier_t = cuda::barrier<cuda::thread_scope_block>;
/* 声明:在 kernel 中调用了 init(...),这里先声明(保持原有代码不变) */
__device__ void init(barrier_t* b, int count);
__device__ void produce(barrier_t ready[], barrier_t filled[], float* buffer, int buffer_len, float* in, int N)
{
for (int i = 0; i < N / buffer_len; ++i)
{
ready[i % 2].arrive_and_wait(); /* wait for buffer_(i%2) to be ready to be filled */
/* produce, i.e., fill in, buffer_(i%2) */
barrier_t::arrival_token token = filled[i % 2].arrive(); /* buffer_(i%2) is filled */
}
}
__device__ void consume(barrier_t ready[], barrier_t filled[], float* buffer, int buffer_len, float* out, int N)
{
barrier_t::arrival_token token1 = ready[0].arrive(); /* buffer_0 is ready for initial fill */
barrier_t::arrival_token token2 = ready[1].arrive(); /* buffer_1 is ready for initial fill */
for (int i = 0; i < N / buffer_len; ++i)
{
filled[i % 2].arrive_and_wait(); /* wait for buffer_(i%2) to be filled */
/* consume buffer_(i%2) */
barrier_t::arrival_token token3 = ready[i % 2].arrive(); /* buffer_(i%2) is ready to be re-filled */
}
}
__global__ void producer_consumer_pattern(int N, float* in, float* out, int buffer_len)
{
constexpr int warpSize = 32;
/* Shared memory buffer declared below is of size 2 * buffer_len
so that we can alternatively work between two buffers.
buffer_0 = buffer and buffer_1 = buffer + buffer_len */
__shared__ extern float buffer[];
/* bar[0] and bar[1] track if buffers buffer_0 and buffer_1 are ready to be filled,
while bar[2] and bar[3] track if buffers buffer_0 and buffer_1 are filled-in respectively */
#pragma nv_diag_suppress static_var_with_dynamic_init
__shared__ barrier_t bar[4];
if (threadIdx.x < 4)
{
init(bar + threadIdx.x, blockDim.x);
}
__syncthreads();
if (threadIdx.x < warpSize)
{
produce(bar, bar + 2, buffer, buffer_len, in, N);
}
else
{
consume(bar, bar + 2, buffer, buffer_len, out, N);
}
}
__device__ void init(barrier_t* b, int count)
{
// 在共享内存位置上构造 barrier,expected count 使用 blockDim.x 传入的 count
::new ((void*)b) barrier_t(static_cast<unsigned int>(count));
}
static inline void checkCuda(cudaError_t e, const char* msg = "")
{
if (e != cudaSuccess)
{
fprintf(stderr, "CUDA error %s: %s\n", msg, cudaGetErrorString(e));
std::exit(1);
}
}
int main()
{
const int N = 1024;
const int buffer_len = 128;
if (N % buffer_len != 0)
{
fprintf(stderr, "N must be divisible by buffer_len\n");
return 1;
}
// 分配并初始化 host 数据(kernel 中的 produce/consume 目前为示例信号控制,
// 未真正移动数据;这里仍然分配并传入,以保证可运行)
float* h_in = (float*)malloc(sizeof(float) * N);
float* h_out = (float*)malloc(sizeof(float) * N);
if (!h_in || !h_out)
{
fprintf(stderr, "Host malloc failed\n");
return 1;
}
for (int i = 0; i < N; ++i) h_in[i] = (float)i;
for (int i = 0; i < N; ++i) h_out[i] = -1.0f;
float* d_in = nullptr;
float* d_out = nullptr;
checkCuda(cudaMalloc(&d_in, sizeof(float) * N), "cudaMalloc d_in");
checkCuda(cudaMalloc(&d_out, sizeof(float) * N), "cudaMalloc d_out");
checkCuda(cudaMemcpy(d_in, h_in, sizeof(float) * N, cudaMemcpyHostToDevice), "memcpy H2D d_in");
checkCuda(cudaMemcpy(d_out, h_out, sizeof(float) * N, cudaMemcpyHostToDevice), "memcpy H2D d_out (init)");
// 启动配置:block 数 1(示例),线程数 >= 33 以区分 producer/consumer
int threads = 64;
int blocks = 1;
size_t sharedBytes = sizeof(float) * 2 * buffer_len; // dynamic shared for two buffers
producer_consumer_pattern<<<blocks, threads, sharedBytes>>>(N, d_in, d_out, buffer_len);
checkCuda(cudaGetLastError(), "kernel launch");
checkCuda(cudaDeviceSynchronize(), "kernel sync");
// 读取回显(注意:原 produce/consume 并未实际将数据移动到 out,这里仅示范运行)
checkCuda(cudaMemcpy(h_out, d_out, sizeof(float) * N, cudaMemcpyDeviceToHost), "memcpy D2H d_out");
printf("Kernel finished. (示例线程/障碍同步已运行;原 produce/consume 未实现数据移动)\n");
// 清理
cudaFree(d_in);
cudaFree(d_out);
free(h_in);
free(h_out);
return 0;
}
这段代码是从官网上得到的,但本机GPU太Low,无法编译运行。请知悉!
七、总结
CUDA的异步编程屏障是一种非常灵活高效的异步线程同步机制。但它也有限制之处,即它只能在NVIDIA Ampere架构的GPU后才能使用。通过利用"到达"与"等待"操作解耦,提供了更丰富的并行化机制,提高了硬件资源的利用率。