CUDA_GPU编程

  __global__ 用于定义核函数,他在 GPU 上执行,从 CPU 通过三重尖括号语法 调用,可以有参数,不可以有返回值。 
而 __device__ 则用于定义设备函数,他在 GPU 上执行,但是从 GPU 上调用的,而且不需要三重尖括号,和普通函数用起来一样,可以有参数,有返回值。
即: host 可以调用 global global 可以调用 device device 可以调用 device
#include <cstdio>
#include <cuda_runtime.h>

__device__ __inline__ void say_hello() {
    printf("Hello, world!\n");
}

__global__ void kernel() {
    say_hello();
}

int main() {
    kernel<<<1, 1>>>();
    cudaDeviceSynchronize();
    return 0;
}

C++中的inline只是一个weak符号,只是一种建议,而CUDA编译器中设立了__inline__这个符号来声明函数为内联。

注意声明为 __inline__ 不一定就保证内联了,如果函数太大编译器可能会放弃内联化。因此 CUDA 还提供 __forceinline__ 这个关键字来强制一个函数为内联。GCC 也有相应的 __attribute__((“always_inline”))

此外,还有 __noinline__ 来禁止内联优化。

__device__ 将函数定义在 GPU 上,而 __host__ 则相反,将函数定义在 CPU 上。

通过 __host__ __device__ 这样的双重修饰符,可以把函数同时定义在 CPU GPU 上,这样 CPU GPU 都可以调用。

使用constexpr 函数自动变成 CPU GPU 都可以调用

这样相当于把 constexpr 函数自动变成修饰 __host__ __device__,从而两边都可以调用。

因为 constexpr 通常都是一些可以内联的函数,数学计算表达式之类的,一个个加上太累了,所以产生了这个需求。

__host__ __device__也可以根据设备的不同进行区分:

__host__ __device__ void cuthead() {
#ifdef __CUDA_ARCH__
    printf("Hello, world from GPU!\n");
#else
    printf("Hello, world from CPU!\n");
#endif // __CUDA_ARCH__

}

__global__ void kernel() {
    cuthead();
}

int main() {
    kernel << <1, 1 >> > ();
    cudaDeviceSynchronize();
    cuthead();
    return 0;
}

像上面这个代码,就是根据运行设备的不同进行区分。

        CUDA 编译器具有多段编译的特点。

        一段代码他会先送到 CPU 上的编译器(通常是系统自带的编译器比如 gcc msvc)生成 CPU 部分的指令码。然后送到真正的 GPU 编译器生成 GPU 指令码。最后再链接成同一个文件,看起来好像只编译了一次一样,实际上你的代码会被预处理很多次。

        他在 GPU 编译模式下会定义 __CUDA_ARCH__ 这个宏,利用 #ifdef 判断该宏是否定义,就可以判断当前是否处于 GPU 模式,从而实现一个函数针对 GPU CPU 生成两份源码级不同的代码。

CUDA也可以支持核函数launch:

 (需要将编译器的rdc改为true)

为什么__global__核函数不能有返回值:

__global__ int kernel() {
    return 42;
}

int main() {
    int ret = kernel << <1, 1 >> > ();
    cudaDeviceSynchronize();
    printf("%d\n", ret);
    return 0;
}

这是因为 kernel 的调用是异步的,返回的时候,并不会实际让 GPU 把核函数执行完毕,必须 cudaDeviceSynchronize() 等待他执行完毕(和线程的 join 很像)。所以,不可能从 kernel 里通过返回值获取 GPU 数据,因为 kernel 返回时核函数并没有真正在 GPU 上执行。所以核函数返回类型必须是 void

__global__ void kernel(int *pret) {
    *pret = 42;
}

int main() {
    int* pret;
    cudaMalloc((int **)&pret, sizeof(int));
    kernel << <1, 1 >> > (pret);
    cudaDeviceSynchronize();//cudaMemcpy自带一次同步,所以这次同步可以省略
    int ret;
    cudaMemcpy(&ret, pret, sizeof(int), cudaMemcpyDeviceToHost);
    printf("%d\n", ret);
    cudaFree(pret);
    return 0;
}

最近新出现了一种统一内存地址技术:Unified Memory

只需把 cudaMalloc 换成 cudaMallocManaged 即可,释放时也是通过 cudaFree这样分配出来的地址,不论在 CPU 还是 GPU 上都是一模一样的,都可以访问。而且拷贝也会自动按需进行(当从 CPU 访问时),无需手动调用 cudaMemcpy,大大方便了编程人员,特别是含有指针的一些数据结构。

网格跨步循环

__global__ void kernel(int* arr, int n) {
    for (int i = threadIdx.x; i < n; i += blockDim.x) {
        arr[i] = i;
    }
}

int main() {
    int n = 7;
    int* arr;
    CHECK(cudaMallocManaged(&arr, n * sizeof(int)));

    kernel << <1, 4 >> > (arr, n);

    CHECK(cudaDeviceSynchronize());
    for (int i = 0; i < n; i++) {
        printf("arr[%d]: %d\n", i, arr[i]);
    }

    cudaFree(arr);
    return 0;
}
无论调用者指定了多少个线程( blockDim ),都能自动根据给定的 n 区间循环,不会越界,也不会漏掉几个元素。
这样一个 for 循环非常符合 CPU 上常见的 parallel for 的习惯,又能自动匹配不同的 blockDim ,看起来非常方便。

C++ 封装 

std::vector 作为模板类,其实有两个模板参数: std::vector<T, AllocatorT >
那为什么我们平时只用了 std::vector<T> 呢?因为第二个参数默认是 std::allocator<T>
也就是 std::vector<T> 等价于 std::vector<T, std::allocator<T>>
std::allocator<T> 的功能是负责分配和释放内存,初始化 T 对象等等。
他具有如下几个成员函数:
T *allocate( size_t n)                        // 分配长度为 n ,类型为 T 的数组,返回其起始地址
void deallocate(T *p, size_t n)        // 释放长度为 n ,起始地址为 p ,类型为 T 的数组

vector 会调用 std::allocator<T> allocate / deallocate 成员函数,他又会去调用标准库的 malloc/free 分配和释放内存空间(即他分配是的 CPU 内存)。
我们可以自己定义一个和 std::allocator<T> 一样具有 allocate/deallocate 成员函数的类,这样就可以“骗过 vector ,让他不是在 CPU 内存中分配,而是 CUDA 的统一内存 (managed) 上分配。
template <class T>
struct CudaAllocator {
    using value_type = T;

    T *allocate(size_t size) {
        T *ptr = nullptr;
        checkCudaErrors(cudaMallocManaged(&ptr, size * sizeof(T)));
        return ptr;
    }

    void deallocate(T *ptr, size_t size = 0) {
        checkCudaErrors(cudaFree(ptr));
    }

    template <class ...Args>
    void construct(T *p, Args &&...args) {
        if constexpr (!(sizeof...(Args) == 0 && std::is_pod_v<T>))
            ::new((void *)p) T(std::forward<Args>(args)...);
    }
};

__global__ void kernel(int *arr, int n) {
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
         i < n; i += blockDim.x * gridDim.x) {
        arr[i] = i;
    }
}

int main() {
    int n = 65536;
    std::vector<int, CudaAllocator<int>> arr(n);

    kernel<<<32, 128>>>(arr.data(), n);

    checkCudaErrors(cudaDeviceSynchronize());
    for (int i = 0; i < n; i++) {
        printf("arr[%d]: %d\n", i, arr[i]);
    }

    return 0;
}

 CUDA 的优势在于对 C++ 的完全支持。所以 __global__ 修饰的核函数自然也是可以为模板函数的。

        调用模板时一样可以用自动参数类型推导,如有手动指定的模板参数(单尖括号)请放在三重尖括号的前面。

template <class Func>
__global__ void parallel_for(int n, Func func) {
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
        i < n; i += blockDim.x * gridDim.x) {
        func(i);
    }
}

struct MyFunctor {
    __device__ void operator()(int i) const {
        printf("number %d\n", i);
    }
};

int main() {
    int n = 65536;

    parallel_for << <32, 128 >> > (n, MyFunctor{});

    CHECK(cudaDeviceSynchronize());

    return 0;
}

核函数也可以实现函数式编程

不过要注意三点:
1. 这里的 Func 不可以是 Func const & ,那样会变成一个指向 CPU 内存地址的指针,从而出错。所以 CPU GPU 的传参必须按值传。
2. 做参数的这个函数必须是一个有着 成员函数 operator() 的类型,即 functor 类。而不能是独立的函数,否则报错。
3. 这个函数必须标记为 __device__ ,即 GPU 上的函数,否则会变成 CPU 上的函数。

template <class Func>
__global__ void parallel_for(int n, Func func) {
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
        i < n; i += blockDim.x * gridDim.x) {
        func(i);
    }
}


int main() {
    int n = 65536;

    parallel_for << <32, 128 >> > (n, [] __device__(int i) {
        printf("number %d \n", i);
    });

    CHECK(cudaDeviceSynchronize());

    return 0;
}

那么晚既然用了函数式编程,那么就可以自然地想到最方便的调用外部变量了。

 上面这个就是我们最常用的方法了,引用过来,然后赋值,但是这里有一个问题,就是虽然arr是被CudaAllocator分配到GPU上的,但是指向arr的指针还是在CPU栈上,所以GPU无法调用CPU栈上的指针,所以会出错。

可能就会想,是不是可以用 [=] 按值捕获,这样捕获到的就是指针了吧?

错了,不要忘了我们说过,vector 的拷贝是深拷贝(绝大多数C++类都是深拷贝,除了智能指针和原始指针)。这样只会把 vector 整个地拷贝到 GPU 上!而不是浅拷贝其起始地址指针。

 所以为了从性能上进行优化,最好还是进行浅拷贝,即只将原始指针进行传入。

template <class Func>
__global__ void parallel_for(int n, Func func) {
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
         i < n; i += blockDim.x * gridDim.x) {
        func(i);
    }
}

int main() {
    int n = 65536;
    std::vector<int, CudaAllocator<int>> arr(n);

    int *arr_data = arr.data();
    parallel_for<<<32, 128>>>(n, [=] __device__ (int i) {
        arr_data[i] = i;
    });

    checkCudaErrors(cudaDeviceSynchronize());
    for (int i = 0; i < n; i++) {
        printf("arr[%d] = %d\n", i, arr[i]);
    }

    return 0;
}

这个的本质原因是arr_data虽然也是在CPU端的指针,但是用了值传递之后就仅仅是将对应的主机内存中的地址复制了过去,所以可以进行更改。

经典案例求sin:

template <class T>
struct CudaAllocator {
    using value_type = T;

    CudaAllocator() noexcept = default;

    template <class U>
    CudaAllocator(const CudaAllocator<U>&) noexcept {}

    T* allocate(std::size_t n) {
        T* ptr = nullptr;
        cudaMallocManaged(&ptr, n * sizeof(T));
        return ptr;
    }

    void deallocate(T* ptr, std::size_t) noexcept {
        cudaFree(ptr);
    }
};
//这个头文件可以直接将vector分配到GPU端

template <class Func>
__global__ void parallel_for(int n, Func func) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = gridDim.x * blockDim.x;
    for (int i = idx; i < n; i += stride) {
        func(i);
    }
}

int main() {
    int n = 65536;
    std::vector<float, CudaAllocator<float>> arr(n);

    parallel_for << <32, 128 >> > (n, [arr = arr.data()] __device__(int i) {
        arr[i] = sinf(i);
    });

    cudaDeviceSynchronize();

    for (int i = 0; i < n; i++) {
        printf("diff = %f\n", arr[i] - sinf(i));
    }

    return 0;
}

我又用传统的方法设计了一个基础的并行求sinf

template <class Func>
__global__ void parallel_for(int n, Func func) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = gridDim.x * blockDim.x;
    for (int i = idx; i < n; i += stride) {
        func(i);
    }
}

__global__ void sinfa(float* a,int n ) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    int stride = gridDim.x * blockDim.x;
    for (int i = idx; i < n; i += stride) {
        a[i] = sinf(i);
    }
}

int main() {
    int n = 65536;
    std::vector<float, CudaAllocator<float>> arr(n);
    std::vector<float> cpu(n);
    int nByte = sizeof(float) * n;
    float* a_h = (float*)malloc(nByte);
    float* res_d = (float*)malloc(nByte);
    float* a_d;
    CHECK(cudaMallocHost((float**)&a_d, nByte));
    CHECK(cudaMemcpy(a_d, a_h, nByte, cudaMemcpyHostToDevice));


    double t = cpuSecond();
    parallel_for << <32, 128 >> > (n, [arr = arr.data()] __device__(int i) {
        arr[i] = sinf(i);
    });
    cudaDeviceSynchronize();
    double h = cpuSecond();
    double monent = h - t; 
    std::cout << "cudalloc time= " << monent << std::endl;


    t = cpuSecond();
    for (int i = 0; i < n; i++)
    {
        cpu[i] = sinf(i);
    }
    h = cpuSecond();
    monent = h - t;
    std::cout << "cpu time = " << monent << std::endl;

    t = cpuSecond();
    sinfa << <32, 128 >> > (a_d, n);
    cudaDeviceSynchronize();
    h = cpuSecond();
    monent = h - t;
    std::cout << "cpu time = " << monent << std::endl;
    CHECK(cudaMemcpy(res_d, a_d, nByte, cudaMemcpyDeviceToHost));

    /*for (int i = 0; i < n; i++) {
        printf("diff = %f\n", arr[i] - cpu[i]);
    }*/
    cudaFree(a_d);
    cudaFree(a_h);
    cudaFree(res_d);
    return 0;
}

并且可以看到传统的sinfa速度还是更快一些,可能是由于 涉及到 arr 数据的读写。这种情况下,可能会引入更多的计算开销和数据传输开销,从而导致整体计算时间较长。

编译器选项:--use_fast_math 

如果开启了 -- use_fast_math 选项,那么所有对 sinf 的调用都会自动被替换成 __ sinf
-- ftz =true 会把极小数 (denormal) 退化为 0
- -prec-div=false 降低除法的精度换取速度。
- -prec- sqrt =false 降低开方的精度换取速度。
-- fmad 因为非常重要,所以默认就是开启的,会自动把 a * b + c 优化成乘加 (FMA) 指令。

开启 --use_fast_math 后会自动开启上述所有

总之一切都是牺牲精度换速度

THRUST:

thrust库是cuda自己提供的库:

虽然自己实现 CudaAllocator 很有趣,也帮助我们理解了底层原理。但是既然 CUDA 官方已经提供了 thrust 库,那就用他们的好啦。
universal_vector 会在统一内存上分配,因此不论 GPU 还是 CPU 都可以直接访问到。
#include<cstdio>
#include <iostream>
#include <vector>
#include <cmath>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <cuda_runtime.h>
#include  "freshman.h"
#include<thrust/universal_vector.h>

template<class Func>
__global__ void parallel_for(int n, Func func) {
	for (int i = blockDim.x*blockIdx.x+threadIdx.x; i < n ; i += blockDim.x*gridDim.x){
		func(i);
	}
}

int main() {

	int n = 65536;
	float a = 3.14f;
	thrust::universal_vector<float> x(n);
	thrust::universal_vector<float> y(n);

	for (int i = 0; i < n; i++){

		x[i] = std::rand() * (1.f / RAND_MAX);
		y[i] = std::rand() * (1.f / RAND_MAX);

	}

	parallel_for << <n / 512, 128 >> > (n, [a, x = x.data(), y = y.data()] __device__(int i) {
		x[i] = a * x[i] + y[i];
	});
	CHECK(cudaDeviceSynchronize());

	return 0;
}

换成分离的host和device

#include<cstdio>
#include <iostream>
#include <vector>
#include <cmath>
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <cuda_runtime.h>
#include  "freshman.h"
#include<thrust/device_vector.h>
#include<thrust/host_vector.h>
template<class Func>
__global__ void parallel_for(int n, Func func) {
	for (int i = blockDim.x*blockIdx.x+threadIdx.x; i < n ; i += blockDim.x*gridDim.x){
		func(i);
	}
}

int main() {

	int n = 65536;
	float a = 3.14f;
	thrust::host_vector<float> x_host(n);
	thrust::host_vector<float> y_host(n);

	for (int i = 0; i < n; i++){

		x_host[i] = std::rand() * (1.f / RAND_MAX);
		y_host[i] = std::rand() * (1.f / RAND_MAX);

	}

	thrust::device_vector<float> x_dev = x_host;
	thrust::device_vector<float> y_dev = y_host;

	parallel_for << <n / 512, 128 >> > (n, [a, x_dev = x_dev.data(), y_dev = y_dev.data()] __device__(int i) {
		x_dev[i] = a * x_dev[i] + y_dev[i];
	});
	x_host = x_dev;
	CHECK(cudaDeviceSynchronize());

	return 0;
}

可以看到性能提升巨大

thrust 提供了很多类似于标准库里的模板函数,比如 thrust::generate(b, e, f) 对标 std::generate,用于批量调用 f() 生成一系列(通常是随机)数,写入到 [b, e) 区间。

其前两个参数是 device_vector host_vector 的迭代器,可以通过成员函数 begin() end() 得到。第三个参数可以是任意函数,这里用了 lambda 表达式。

同理,还有 thrust::for_each(b, e, f) 对标 std::for_each。他会把 [b, e) 区间的每个元素 x 调用一遍 f(x)。这里的 x 实际上是一个引用。如果 b e 是常值迭代器则是个常引用,可以用 cbegin()cend() 获取常迭代器。

当然还有 thrust::reducethrust::sortthrust::find_ifthrust::count_ifthrust::reversethrust::inclusive_scan 等。

 而且thrust可以自动根据device_vector还是host_vector来决定是在CPU还是GPU上执行。

这就是为什么我们用于 x_host 那个 for_each lambda 没有修饰,而用于 x_dev 的那个 lambda 需要修饰 __device__

合并多个迭代器为一个:zip_iterator

可以用 thrust:: make_zip_iterator (a, b) 把多个迭代器合并起来,相当于 Python 里的 zip

然后在函数体里通过 auto const &tup 捕获,并通过 thrust::get<index>(tup) 获取这个合并迭代器的第 index 个元素

thrust::for_each(
        thrust::make_zip_iterator(x_dev.begin(), y_dev.cbegin()),
        thrust::make_zip_iterator(x_dev.end(), y_dev.cend()),
        [a] __device__ (auto const &tup) {
        auto &x = thrust::get<0>(tup);
        auto const &y = thrust::get<1>(tup);
        x = a * x + y;
    });

在这段代码中,auto const &tup 是用来接收迭代器指向的元组类型的参数,并且是一个常量引用。这种设计选择是为了确保在 lambda 表达式中不会对元组进行修改。

这种做法是出于一种良好的设计原则,即尽可能地使用常量引用,以避免不必要的拷贝和修改。在这种情况下,既不需要拷贝元组,也不需要修改它,所以选择使用 auto const &tup 来确保元组的值不被改变。

因此,auto const &tup 的设计选择是为了强调 lambda 表达式中对元组的只读访问,以增加代码的清晰性、可维护性和安全性。

thrust::sort 例子


#include <stdio.h>
#include <thrust/host_vector.h>
#include <thrust/device_vector.h>
#include <thrust/sort.h>
#include <iostream>
using namespace std;

__host__ __device__
int sort_func(int a, int b){
    return a > b;
}

int main(){

    int data[] = {5, 3, 1, 5, 2, 0};
    int ndata  = sizeof(data) / sizeof(data[0]);
    thrust::host_vector<int> array1(data, data + ndata);
    thrust::sort(array1.begin(), array1.end(), sort_func);

    thrust::device_vector<int> array2 = thrust::host_vector<int>(data, data + ndata);
    thrust::sort(array2.begin(), array2.end(), []__device__(int a, int b){return a < b;});

    printf("array1------------------------\n");
    for(int i = 0; i < array1.size(); ++i)
        cout << array1[i] << endl;

    printf("array2------------------------\n");
    for(int i = 0; i < array2.size(); ++i)
        cout << array2[i] << endl;
    return 0;
}

原子操作:

主要主题同:https://blog.csdn.net/zhuangtu1999/article/details/130835666?spm=1001.2014.3001.5501

主要对于atomicCAS进行进一步讲解:

atomicCAS:原子地判断是否相等,相等则写入,并读取旧值

old = atomicCAS ( dst , cmp , src ) 相当于:
old = * dst ;
if (old == cmp )
 * dst = src ;

 类似的,也可以尝试atomicMul这种。

 而原子操作也有很多的问题:最大的问题就是影响性能。因为原子操作要保证同一时刻只能有一个线程在修改地址,如果多个线程同时修改同一个就需要像排队那样,一个线程修改完另一个线程才能进去。

/*

当一个线程执行原子操作时,它会尝试使用硬件提供的原子操作指令对共享资源进行修改。如果该操作成功,其他线程在执行相同的原子操作时将会失败,因为硬件会自动保证同一时刻只有一个线程能够成功执行原子操作。

如果其他线程在执行原子操作时发现资源已经被锁定,它们将被阻塞或者进入自旋等待的状态。这些线程会持续尝试执行原子操作,直到资源被解锁,并且它们能够成功地执行原子操作为止。这种等待的机制通常由操作系统的调度器或硬件提供的机制来管理。

具体的等待机制可能因操作系统和硬件的不同而有所区别。一种常见的等待机制是自旋锁,它会在等待期间一直尝试获取锁,而不是进入睡眠状态。这可以减少线程从睡眠状态唤醒的开销,但也可能导致CPU资源被浪费。另一种等待机制是线程的阻塞,其中线程会被挂起并等待唤醒,直到资源可用。

*/

而应对于这个,就要提出线程局部变量

解决方法之一就是:先累加到局部变量 local_sum ,最后一次性累加到全局的 sum

这样每个线程就只有一次原子操作,而不是网格跨步循环的那么多次原子操作了。当然,我们需要调小 gridDim * blockDim 使其远小于 n,这样才能够减少原子操作的次数。这就是胡渊鸣所说的 TLS 

__global__ void parallel_sum(int *sum, int const *arr, int n) {
    int local_sum = 0;
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
         i < n; i += blockDim.x * gridDim.x) {
        local_sum += arr[i];
    }
    atomicAdd(&sum[0], local_sum);
}

板块与共享内存:

刚刚的数组求和例子,其实可以不需要原子操作。

刚刚我们直接用了一个 for 循环迭代所有1024个元素,实际上内部仍然是一个串行的过程,数据是强烈依赖的(local_sum += arr[j] 可以体现出,下一时刻的 local_sum 依赖于上一时刻的 local_sum

要消除这种依赖,可以通过右边这样的逐步缩减,这样每个 for 循环内部都是没有数据依赖,从而是可以并行的。

__global__ void parallel_sum(int *sum, int const *arr, int n) {
    for (int i = blockDim.x * blockIdx.x + threadIdx.x;
         i < n / 1024; i += blockDim.x * gridDim.x) {
        int local_sum[1024];
        for (int j = 0; j < 1024; j++) {
            local_sum[j] = arr[i * 1024 + j];
        }
        for (int j = 0; j < 512; j++) {
            local_sum[j] += local_sum[j + 512];
        }
        for (int j = 0; j < 256; j++) {
            local_sum[j] += local_sum[j + 256];
        }
        for (int j = 0; j < 128; j++) {
            local_sum[j] += local_sum[j + 128];
        }
        for (int j = 0; j < 64; j++) {
            local_sum[j] += local_sum[j + 64];
        }
        for (int j = 0; j < 32; j++) {
            local_sum[j] += local_sum[j + 32];
        }
        for (int j = 0; j < 16; j++) {
            local_sum[j] += local_sum[j + 16];
        }
        for (int j = 0; j < 8; j++) {
            local_sum[j] += local_sum[j + 8];
        }
        for (int j = 0; j < 4; j++) {
            local_sum[j] += local_sum[j + 4];
        }
        for (int j = 0; j < 2; j++) {
            local_sum[j] += local_sum[j + 2];
        }
        for (int j = 0; j < 1; j++) {
            local_sum[j] += local_sum[j + 1];
        }
        sum[i] = local_sum[0];
    }
}

 •刚刚已经实现了无数据依赖可以并行的 for,那么如何把他真正变成并行的呢?这就是板块的作用了,我们可以把刚刚的线程升级为板块,刚刚的 for 升级为线程,然后把刚刚 local_sum 这个线程局部数组升级为板块局部数组。那么如何才能实现板块局部数组呢?

__global__ void parallel_sum(int *sum, int const *arr, int n) {
    __shared__ volitale int local_sum[1024];
    int j = threadIdx.x;
    int i = blockIdx.x;
    local_sum[j] = arr[i * 1024 + j];
    __syncthreads();
    if (j < 512) {
        local_sum[j] += local_sum[j + 512];
    }
    __syncthreads();
    if (j < 256) {
        local_sum[j] += local_sum[j + 256];
    }
    __syncthreads();
    if (j < 128) {
        local_sum[j] += local_sum[j + 128];
    }
    __syncthreads();
    if (j < 64) {
        local_sum[j] += local_sum[j + 64];
    }
    __syncthreads();
    if (j < 32) {
        local_sum[j] += local_sum[j + 32];
    }
    __syncthreads();
    if (j < 16) {
        local_sum[j] += local_sum[j + 16];
    }
    __syncthreads();
    if (j < 8) {
        local_sum[j] += local_sum[j + 8];
    }
    __syncthreads();
    if (j < 4) {
        local_sum[j] += local_sum[j + 4];
    }
    __syncthreads();
    if (j < 2) {
        local_sum[j] += local_sum[j + 2];
    }
    __syncthreads();
    if (j == 0) {
        sum[i] = local_sum[0] + local_sum[1];
    }
}
而且warp是以32个线程为单位的,所以对于j<=32的就不需要syncthreads了。

而且是这样做会引起线程组分歧,这样虽然会增加运算的次数,但反而会更快。

 

可以看到确实有提速。 

但是要是运用上网格跨步,能更快

__global__ void parallel_sum_kernel(T *sum, T const *arr, int n) {
    __shared__ volatile int local_sum[blockSize];
    int j = threadIdx.x;
    int i = blockIdx.x;
    T temp_sum = 0;
    for (int t = i * blockSize + j; t < n; t += blockSize * gridDim.x) {
        temp_sum += arr[t];
    }
    local_sum[j] = temp_sum;
    __syncthreads();
    if constexpr (blockSize >= 1024) {
        if (j < 512)
            local_sum[j] += local_sum[j + 512];
        __syncthreads();
    }

使用板块局部数组(共享内存)来加速数组求和

这就是 BLSblock-local storage

可以发现,使用atomicadd的方法可以达到近似的效果,这也是由于atomicAdd被编译器自动优化为了BLS。

__global__ void parallel_sum(int* sum, int const* arr, int n) {
    int local_sum = 0;
    for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += blockDim.x * gridDim.x)
    {
        local_sum += arr[i];
    }
    atomicAdd(&sum[0], local_sum);

}

shared_memory   进阶

 每个SM都有固定数量的寄存器,但是会有问题,

如果blockDim数量太大,就会导致每个SM里的线程数过多。就会造成寄存器打翻现象

GPU 线程的寄存器,实际上也是一块比较小而块的内存,称之为寄存器仓库( register file )。板块内的所有的线程共用一个寄存器 仓库。
当板块中的线程数量( blockDim )过多时,就会导致每个线程能够分配到的寄存器数量急剧缩小。而如果你的程序恰好用到了非常多的寄存器,那就没办法全部装在高效的寄存器仓库里,而是要把一部分“打翻”到一级缓存中,这时对这些寄存器读写的速度就和一级缓存一样,相对而言低效了。若一级缓存还装不下,那会打翻到所有 SM 共用的二级缓存。
此外,如果在线程局部分配一个数组,并通过动态下标访问(例如遍历 BVH 时用到的模拟栈),那无论如何都是会打翻到一级缓存的,因为寄存器不能动态寻址。

而如果blockDim的数量太小,就会造成 延迟隐藏失效 的情况 

我们说过,每个 SM 一次只能执行板块中的一个线程组( warp ),也就是 32 个线程
而当线程组陷入内存等待时,可以切换到另一个线程,继续计算,这样一个 warp 的内存延迟就被另一个 warp 的计算延迟给隐藏起来了。因此,如果线程数量太少的话,就无法通过在多个 warp 之间调度来隐藏内存等待的延迟,从而低效。
此外,最好让 板块中的线程数量( blockDim )为 32 的整数倍,否则假如是 33 个线程的话,那还是需要启动两个 warp ,其中第二个 warp 只有一个线程是有效的,非常浪费。
结论:对于使用寄存器较少、访存为主的核函数(例如矢量加法),使用大 blockDim 为宜。反之(例如光线追踪)使用小 blockDim ,但也不宜太小。

来看一个最基本的矩阵转置:

template <class T>
__global__ void parallel_transform(T* out, T const* in, int nx, int ny) {
    int tid = blockDim.x * blockIdx.x + threadIdx.x;
    int y = tid / nx;
    int x = tid % nx;
    if (x >= nx || y >= ny) return;
    out[y * nx + x] = in[x * nx + y];
}
int main() {
    int nx = 1 << 14, ny = 1 << 14;
    std::vector<int, CudaAllocator<int>> in(nx * ny);
    std::vector<int, CudaAllocator<int>> out(nx * ny);

    for (int i = 0; i < nx * ny; i++) {
        in[i] = i;
    }

    TICK(parallel_transpose);
    parallel_transform << <nx * ny / 1024, 1024 >> >
        (out.data(), in.data(), nx, ny);
    CHECK(cudaDeviceSynchronize());
    TOCK(parallel_transpose);

    for (int y = 0; y < ny; y++) {
        for (int x = 0; x < nx; x++) {
            if (out[y * nx + x] != in[x * nx + y]) {
                printf("Wrong At x=%d,y=%d: %d != %d\n", x, y,
                    out[y * nx + x], in[x * nx + y]);
                return -1;
            }
        }
    }

    printf("All Correct!\n");
    return 0;
}

 

 可以看到,这里out是按行读取了,但in没有,所以很低效,在cpu中可以用for来进行循环分块,但在这里没有for那怎么分块呢?

不要忘记这里是GPU啊,nx,ny都有dim3的,有天然的block可以进行分块啊!!!

template <class T>
__global__ void parallel_transform(T* out, T const* in, int nx, int ny) {
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    int y = blockDim.y * blockIdx.y + threadIdx.y;
    if (x >= nx || y >= ny) return;
    out[y * nx + x] = in[x * nx + y];
}
int main() {
    int nx = 1 << 14, ny = 1 << 14;
    std::vector<int, CudaAllocator<int>> in(nx * ny);
    std::vector<int, CudaAllocator<int>> out(nx * ny);

    for (int i = 0; i < nx * ny; i++) {
        in[i] = i;
    }

    TICK(parallel_transpose);
    parallel_transform << <dim3(nx/32, ny / 32,1),dim3(32,32,1) >> >
        (out.data(), in.data(), nx, ny);
    CHECK(cudaDeviceSynchronize());
    TOCK(parallel_transpose);

    for (int y = 0; y < ny; y++) {
        for (int x = 0; x < nx; x++) {
            if (out[y * nx + x] != in[x * nx + y]) {
                printf("Wrong At x=%d,y=%d: %d != %d\n", x, y,
                    out[y * nx + x], in[x * nx + y]);
                return -1;
            }
        }
    }

    printf("All Correct!\n");
    return 0;
}

  

 可以看到性能提升巨大。

  • 刚刚那样的话对 in 的读取是存在跨步的,而 GPU 喜欢连续的顺序读取,这样跨步就不高效了。
  • 但是因为我们的目的是做矩阵转置,无论是 in 还是 out 必然有一个是需要跨步的,怎么办?
  • 因此可以先通过把 in 分块,按块跨步地读,而块内部则仍是连续地读——从低效全局的内存读到高效的共享内存中,然后在共享内存中跨步地读,连续地写到 out 指向的低效的全局内存中。
  • 这样跨步的开销就开在高效的共享内存上,而不是低效的全局内存上,因此会变快。

template <int blockSize , class T>
__global__ void parallel_transform(T* out, T const* in, int nx, int ny) {
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    int y = blockDim.y * blockIdx.y + threadIdx.y;

    if (x >= nx || y >= ny) return;
    __shared__ T tmp[blockSize * blockSize];
    //把in分块,按块跨步,就没有按线程那么大的开销
    int rx = blockIdx.y * blockSize + threadIdx.x;
    int ry = blockIdx.x * blockSize + threadIdx.y;

    tmp[threadIdx.y * blockSize + threadIdx.x] = in[ry * nx + rx];
    __syncthreads();
    out[y * nx + x] = tmp[threadIdx.x * blockSize + threadIdx.y];
}
int main() {
    int nx = 1 << 14, ny = 1 << 14;
    std::vector<int, CudaAllocator<int>> in(nx * ny);
    std::vector<int, CudaAllocator<int>> out(nx * ny);

    for (int i = 0; i < nx * ny; i++) {
        in[i] = i;
    }

    TICK(parallel_transpose);
    parallel_transform<32> << <dim3(nx/32, ny / 32,1),dim3(32,32,1) >> >
        (out.data(), in.data(), nx, ny);
    CHECK(cudaDeviceSynchronize());
    TOCK(parallel_transpose);
    
    ....

    printf("All Correct!\n");
    return 0;
}

 

可以看到速度提升了一些。

Bank conflict

而刚刚那个矩阵转置的例子,这里的 blockSize 32可以看到第一个对 tmp 的访问是没冲突的。

而分析一下第二个对 tmp 的访问:threadIdx=(0,0) 线程 0 会访问 tmp[0] 位于 bank 0threadIdx=(0,1) 的线程 1 会访问 tmp[32] 也位于 bank 0threadIdx=(0,1) 的线程 2 会访问 tmp[64] 也位于 bank 0……也就是说,同一个 warp 的所有线程都在访问 bank 0

所以就可以使用移位的方法来减少bank conflict

template <int blockSize , class T>
__global__ void parallel_transform(T* out, T const* in, int nx, int ny) {
    int x = blockDim.x * blockIdx.x + threadIdx.x;
    int y = blockDim.y * blockIdx.y + threadIdx.y;

    if (x >= nx || y >= ny) return;
    __shared__ T tmp[(blockSize+1) * blockSize];
    //把in分块,按块跨步,就没有按线程那么大的开销
    int rx = blockIdx.y * blockSize + threadIdx.x;
    int ry = blockIdx.x * blockSize + threadIdx.y;

    tmp[threadIdx.y * (blockSize+1) + threadIdx.x] = in[ry * nx + rx];
    __syncthreads();
    out[y * nx + x] = tmp[threadIdx.x * (blockSize+1) + threadIdx.y];
}

 但是这样好像效果提升的也没有很好,感觉是blocksize设置的不太好,我再改改。

总结

线程组分歧( wrap divergence ):尽量保证 32 个线程都进同样的分支,否则两个分支都会执行。
延迟隐藏( latency hiding ):需要有足够的 blockDim SM 在陷入内存等待时调度到其他线程组。
寄存器打翻( register spill ):如果核函数用到很多局部变量(寄存器),则 blockDim 不宜太大。
共享内存( shared memory ):全局内存比较低效,如果需要多次使用,可以先读到共享内存。
跨步访问(coalesced acccess ):建议先顺序读到共享内存,让高带宽的共享内存来承受跨步。
区块冲突( bank conflict ):同一个 warp 中多个线程访问共享内存中模 32 相等的地址会比较低效,可以把数组故意搞成不对齐的 33 跨步来避免。

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
解释:if(CUDA_FOUND) message(STATUS "Found CUDA Toolkit v${CUDA_VERSION_STRING}") enable_language(CUDA) set(HAVE_CUDA TRUE) if (CMAKE_CUDA_COMPILER_ID STREQUAL "NVIDIA") if(${CUDA_VERSION_STRING} VERSION_GREATER_EQUAL "11.1") execute_process(COMMAND ${CMAKE_CUDA_COMPILER} --list-gpu-code RESULT_VARIABLE EXIT_CODE OUTPUT_VARIABLE OUTPUT_VAL) if(EXIT_CODE EQUAL 0) #Remove sm_ string(REPLACE "sm_" "" OUTPUT_VAL ${OUTPUT_VAL}) #Convert to list string(REPLACE "\n" ";" __CUDA_ARCH_BIN ${OUTPUT_VAL}) #Remove last empty entry list(REMOVE_AT __CUDA_ARCH_BIN -1) else() message(FATAL_ERROR "Failed to run NVCC to get list of GPU codes: ${EXIT_CODE}") endif() elseif(${CUDA_VERSION_STRING} VERSION_GREATER_EQUAL "11.0") set(__CUDA_ARCH_BIN "35;37;50;52;53;60;61;62;70;72;75;80") elseif(${CUDA_VERSION_STRING} VERSION_GREATER_EQUAL "10.0") set(__CUDA_ARCH_BIN "30;32;35;37;50;52;53;60;61;62;70;72;75") elseif(${CUDA_VERSION_STRING} VERSION_GREATER_EQUAL "9.1") set(__CUDA_ARCH_BIN "30;32;35;37;50;52;53;60;61;62;70;72") else() set(__CUDA_ARCH_BIN "30;32;35;37;50;52;53;60;61;62;70") endif() else() message(FATAL_ERROR "Unsupported CUDA compiler ${CMAKE_CUDA_COMPILER_ID}.") endif() set(CUDA_ARCH_BIN ${__CUDA_ARCH_BIN} CACHE STRING "Specify 'real' GPU architectures to build binaries for") if(POLICY CMP0104) cmake_policy(SET CMP0104 NEW) set(CMAKE_CUDA_ARCHITECTURES ${CUDA_ARCH_BIN}) message(STATUS "CMAKE_CUDA_ARCHITECTURES: ${CMAKE_CUDA_ARCHITECTURES}") #Add empty project as its not required with newer CMake add_library(pcl_cuda INTERFACE) else() # Generate SASS set(CMAKE_CUDA_ARCHITECTURES ${CUDA_ARCH_BIN}) # Generate PTX for last architecture list(GET CUDA_ARCH_BIN -1 ver) set(CMAKE_CUDA_FLAGS "${CMAKE_CUDA_FLAGS} -gencode arch=compute_${ver},code=compute_${ver}") message(STATUS "CMAKE_CUDA_FLAGS: ${CMAKE_CUDA_FLAGS}") add_library(pcl_cuda INTERFACE) target_include_directories(pcl_cuda INTERFACE ${CUDA_TOOLKIT_INCLUDE}) endif () endif()
05-30

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值