在CUDA中实现临界区

13

我正在尝试使用原子指令在CUDA中实现一个关键段,但是遇到了一些问题。我已经创建了一个测试程序来展示这个问题:

#include <cuda_runtime.h>
#include <cutil_inline.h>
#include <stdio.h>

__global__ void k_testLocking(unsigned int* locks, int n) {
    int id = threadIdx.x % n;
    while (atomicExch(&(locks[id]), 1u) != 0u) {} //lock
    //critical section would go here
    atomicExch(&(locks[id]),0u); //unlock
}

int main(int argc, char** argv) {
    //initialize the locks array on the GPU to (0...0)
    unsigned int* locks;
    unsigned int zeros[10]; for (int i = 0; i < 10; i++) {zeros[i] = 0u;}
    cutilSafeCall(cudaMalloc((void**)&locks, sizeof(unsigned int)*10));
    cutilSafeCall(cudaMemcpy(locks, zeros, sizeof(unsigned int)*10, cudaMemcpyHostToDevice));

    //Run the kernel:
    k_testLocking<<<dim3(1), dim3(256)>>>(locks, 10);

    //Check the error messages:
    cudaError_t error = cudaGetLastError();
    cutilSafeCall(cudaFree(locks));
    if (cudaSuccess != error) {
        printf("error 1: CUDA ERROR (%d) {%s}\n", error, cudaGetErrorString(error));
        exit(-1);
    }
    return 0;
}

很遗憾,这段代码会使我的机器冻结数秒钟,最终退出并打印出以下消息:

fcudaSafeCall() Runtime API error in file <XXX.cu>, line XXX : the launch timed out and was terminated.
这意味着其中一个while循环没有返回,但似乎这应该是有效的。 提醒一下,atomicExch(unsigned int* address, unsigned int val) 原子地将存储在地址中的内存位置的值设置为 val 并返回值。因此,我的锁定机制的想法是它最初为0u,因此一个线程应该通过while循环,所有其他线程应该等待while循环,因为他们将读取locks[id]作为1u。然后当线程完成临界区时,它将锁定重置为0u ,以便另一个线程可以进入。
我错过了什么吗?
顺便说一下,我正在使用以下编译:
nvcc -arch sm_11 -Ipath/to/cuda/C/common/inc XXX.cu
4个回答

23

好的,我找到解决方法了,这又是一个CUDA范式的痛点。

任何一位优秀的CUDA程序员都知道(请注意,我没有记住这一点,这使我成为了一个不好的CUDA程序员),warp中的所有线程必须执行相同的代码。如果没有这个事实,我编写的代码将完美地工作。然而,现在很可能有两个线程在同一个warp中访问同一个锁。如果其中一个线程获取了锁,它就会忘记执行循环,但它不能继续超过循环,直到其warp中的所有其他线程都完成了该循环。不幸的是,其他线程永远无法完成,因为它正在等待第一个线程解锁。

下面是可以正确执行的内核:

__global__ void k_testLocking(unsigned int* locks, int n) {
    int id = threadIdx.x % n;
    bool leaveLoop = false;
    while (!leaveLoop) {
        if (atomicExch(&(locks[id]), 1u) == 0u) {
            //critical section
            leaveLoop = true;
            atomicExch(&(locks[id]),0u);
        }
    } 
}

这个问题在 NVIDIA 论坛上已经讨论了多次。我认为结论是,只有当您可以确保块的数量小于或等于多处理器的数量时,才能正常工作。否则,可能会导致死锁。换句话说,尝试找到另一种不需要关键部分的算法实现方式。 - Eric
1
起初我并没有理解你的解释,认为warp divergence实际上允许同一warp中的线程执行不同的操作。对于像我这样的未来读者,我想补充说明的是,同一warp中的线程可能会执行不同的指令,但是当一些线程执行一个分支时,其他线程将被禁用,直到该分支完成 - AkiRoss
为什么我不能用locks[id] = 0u;替换atomicExch(&(locks[id]),0u);?(尝试过,但不起作用) - whenov
@John 你能解释一下你的解决方案吗? - Silicomancer
@Eric,你能提供任何说明(链接?),解释为什么上述代码会在你所提到的情况下导致死锁吗? - Silicomancer

13
海报已经找到了自己问题的答案。然而,在下面的代码中,我提供了一个在CUDA中实现“临界区”的通用框架。更详细地说,该代码执行块计数,但很容易修改以托管在“临界区”中执行的其他操作。下面,我还报告了一些对代码的解释,并列出了在CUDA中实现“临界区”时的一些“典型”错误。 代码如下:
#include <stdio.h>

#include "Utilities.cuh"

#define NUMBLOCKS  512
#define NUMTHREADS 512 * 2

/***************/
/* LOCK STRUCT */
/***************/
struct Lock {

    int *d_state;

    // --- Constructor
    Lock(void) {
        int h_state = 0;                                        // --- Host side lock state initializer
        gpuErrchk(cudaMalloc((void **)&d_state, sizeof(int)));  // --- Allocate device side lock state
        gpuErrchk(cudaMemcpy(d_state, &h_state, sizeof(int), cudaMemcpyHostToDevice)); // --- Initialize device side lock state
    }

    // --- Destructor
    __host__ __device__ ~Lock(void) { 
#if !defined(__CUDACC__)
        gpuErrchk(cudaFree(d_state)); 
#else

#endif  
    }

    // --- Lock function
    __device__ void lock(void) { while (atomicCAS(d_state, 0, 1) != 0); }

    // --- Unlock function
    __device__ void unlock(void) { atomicExch(d_state, 0); }
};

/*************************************/
/* BLOCK COUNTER KERNEL WITHOUT LOCK */
/*************************************/
__global__ void blockCountingKernelNoLock(int *numBlocks) {

    if (threadIdx.x == 0) { numBlocks[0] = numBlocks[0] + 1; }
}

/**********************************/
/* BLOCK COUNTER KERNEL WITH LOCK */
/**********************************/
__global__ void blockCountingKernelLock(Lock lock, int *numBlocks) {

    if (threadIdx.x == 0) {
        lock.lock();
        numBlocks[0] = numBlocks[0] + 1;
        lock.unlock();
    }
}

/****************************************/
/* BLOCK COUNTER KERNEL WITH WRONG LOCK */
/****************************************/
__global__ void blockCountingKernelDeadlock(Lock lock, int *numBlocks) {

    lock.lock();
    if (threadIdx.x == 0) { numBlocks[0] = numBlocks[0] + 1; }
    lock.unlock();
}

/********/
/* MAIN */
/********/
int main(){

    int h_counting, *d_counting;
    Lock lock;

    gpuErrchk(cudaMalloc(&d_counting, sizeof(int)));

    // --- Unlocked case
    h_counting = 0;
    gpuErrchk(cudaMemcpy(d_counting, &h_counting, sizeof(int), cudaMemcpyHostToDevice));

    blockCountingKernelNoLock << <NUMBLOCKS, NUMTHREADS >> >(d_counting);
    gpuErrchk(cudaPeekAtLastError());
    gpuErrchk(cudaDeviceSynchronize());

    gpuErrchk(cudaMemcpy(&h_counting, d_counting, sizeof(int), cudaMemcpyDeviceToHost));
    printf("Counting in the unlocked case: %i\n", h_counting);

    // --- Locked case
    h_counting = 0;
    gpuErrchk(cudaMemcpy(d_counting, &h_counting, sizeof(int), cudaMemcpyHostToDevice));

    blockCountingKernelLock << <NUMBLOCKS, NUMTHREADS >> >(lock, d_counting);
    gpuErrchk(cudaPeekAtLastError());
    gpuErrchk(cudaDeviceSynchronize());

    gpuErrchk(cudaMemcpy(&h_counting, d_counting, sizeof(int), cudaMemcpyDeviceToHost));
    printf("Counting in the locked case: %i\n", h_counting);

    gpuErrchk(cudaFree(d_counting));
}

代码解释

关键段是必须由CUDA线程按顺序执行的操作序列。

假设构造一个内核,其任务是计算线程格的线程块数。一种可能的想法是让每个块中的每个线程,其中threadIdx.x == 0,增加一个全局计数器。为了防止竞争条件,所有增加必须按顺序进行,因此它们必须被包含在关键段中。

上述代码有两个内核函数:blockCountingKernelNoLockblockCountingKernelLock。前者不使用关键段来增加计数器,并且如可以看到的那样,返回错误的结果。后者将计数器增加封装在关键段中,因此产生正确的结果。但是关键段是如何工作的呢?

关键段由全局状态d_state控制。最初,状态为0。此外,两个__device__方法,lockunlock,可以更改此状态。只有每个块内的单个线程,特别是具有本地线程索引threadIdx.x == 0的线程可以调用lockunlock方法。

在执行期间的随机时间,具有本地线程索引threadIdx.x == 0和全局线程索引为t的线程之一将首先调用lock方法。特别是,它将启动atomicCAS(d_state, 0, 1)。由于最初d_state == 0,然后d_state将被更新为1atomicCAS将返回0,线程将退出lock函数,传递到更新指令。同时,所有其他块的所有其他线程,其中threadIdx.x == 0,将执行lock方法。但是,它们将发现d_state的值等于1,因此atomicCAS(d_state, 0, 1)不会执行更新,并将返回1,从而使这些线程运行while循环。在该线程执行上述操作时,线程最终完成更新,然后执行unlock函数,即atomicExch(d_state, 0),从而将d_state恢复为0。此时,随机地,另一个具有threadIdx.x == 0的线程会再次锁定该状态。

上述代码还包含了第三个内核函数,即blockCountingKernelDeadlock。然而,这是另一种错误的临界区实现方式,会导致死锁。事实上,我们需要注意的是warp以锁步操作,并在每条指令后进行同步。因此,当我们执行blockCountingKernelDeadlock时,有可能一个warp中的某个线程(例如一个本地线程索引为t≠0的线程)会锁定状态。在这种情况下,同一个warp中的其他线程,包括那些threadIdx.x == 0的线程,都会执行与线程t相同的while循环语句,因为同一个warp中的线程是以锁步方式执行的。因此,所有线程都将等待有人解锁状态,但没有其他线程能够这样做,代码就会陷入死锁。

3

顺便提一下,您需要记住,在代码中写入全局内存时,并非在该处完成全局内存写入和读取。因此,为了实现这一点,您需要添加全局内存屏障,即__threadfence()。


0
@Vitality。我无法在您的回复下发表评论,所以我在这里写了一个答案。
我已经运行了您的代码,似乎lockno lock版本都会输出一个不正确的答案。我认为其原因是您没有以原子方式更改numBlocks的值。 假设线程t1获取锁并执行代码numBlocks [0] = numBlocks [0] + 1;,然后释放锁。现在另一个线程t2获取锁并读取numBlocks中的值。由于t1执行的操作不是原子操作,t2读取的值可能是旧值。因此我们将得到一个不正确的值。
我们可以通过用atomicAdd(numBlocks, 1)替换代码numBlocks[0] = numBlocks[0] + 1来纠正它。感谢您的通用代码和清晰的解释。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接