《GPU高性能编程 CUDA实战》(CUDA By Example)读书笔记

转自:https://www.jianshu.com/p/4477174c12f6

写在最前
这本书是2011年出版的,按照计算机的发展速度来说已经算是上古书籍了,不过由于其简单易懂,仍旧被推荐为入门神书。先上封面: 
 
由于书比较老,而且由于学习的目的不同,这里只介绍了基础代码相关的内容,跳过了那些图像处理的内容。 
另外这本书的代码这里:csdn资源

前两章 科普
就各种讲CUDA的变迁,然后第二章讲如何安装CUDA。不会安装的请移步这里:安装CUDA.

第三章 CUDA C简介
输出hello world


#include<stdio.h>

__global__ void kernel() {
  printf("hello world");
}

int main() {
  kernel<<<1, 1>>>();
  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
这个程序和普通的C程序的区别值得注意

函数的定义带有了__global__这个标签,表示这个函数是在GPU上运行
函数的调用除了常规的参数之外,还增加了<<<>>>修饰。而其中的数字将传递个CUDA的运行时系统,至于能干啥,下一章会讲。
进阶版


#include<stdio.h>

__global__ void add(int a,int b,int *c){
  *c = a + b;
}
int main(){
  int c;
  int *dev_c;
  cudaMalloc((void**)&dev_c,sizeof(int));
  add<<<1,1>>>(2,7,dev_c);
  cudaMemcpy(&c,dev_c,sizeof(int),cudaMemcpyDeviceToHost);
  printf("2 + 7 = %d",c);
  return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
这里就涉及了GPU和主机之间的内存交换了,cudaMalloc是在GPU的内存里开辟一片空间,然后通过操作之后,这个内存里有了计算出来内容,再通过cudaMemcpy这个函数把内容从GPU复制出来。就是这么简单。

第四章 CUDA C并行编程
这一章开始体现CUDA并行编程的魅力。 
以下是一个数组求和的代码

#include<stdio.h>

#define N   10

__global__ void add( int *a, int *b, int *c ) {
    int tid = blockIdx.x;    // this thread handles the data at its thread id
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}

int main( void ) {
    int a[N], b[N], c[N];
    int *dev_a, *dev_b, *dev_c;

    // allocate the memory on the GPU
    cudaMalloc( (void**)&dev_a, N * sizeof(int) );
    cudaMalloc( (void**)&dev_b, N * sizeof(int) );
    cudaMalloc( (void**)&dev_c, N * sizeof(int) );

    // fill the arrays 'a' and 'b' on the CPU
    for (int i=0; i<N; i++) {
        a[i] = -i;
        b[i] = i * i;
    }

    // copy the arrays 'a' and 'b' to the GPU
    cudaMemcpy( dev_a, a, N * sizeof(int),
                              cudaMemcpyHostToDevice );
    cudaMemcpy( dev_b, b, N * sizeof(int),
                              cudaMemcpyHostToDevice );

    add<<<N,1>>>( dev_a, dev_b, dev_c );

    // copy the array 'c' back from the GPU to the CPU
    cudaMemcpy( c, dev_c, N * sizeof(int),
                              cudaMemcpyDeviceToHost );

    // display the results
    for (int i=0; i<N; i++) {
        printf( "%d + %d = %d\n", a[i], b[i], c[i] );
    }

    // free the memory allocated on the GPU
    cudaFree( dev_a );
    cudaFree( dev_b );
    cudaFree( dev_c );
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
重点也是对于初学者最难理解的就是kernel函数了:

 __global__ void add( int *a, int *b, int *c ) {
    int tid = blockIdx.x;
    if (tid < N)
        c[tid] = a[tid] + b[tid];
}
1
2
3
4
5
GPU编程和CPU编程的最大区别也就在这里体现出来了,就是数组求和竟然不要循环!为什么不要循环,就是因为这里的tid可以把整个循环的工作做了。这里的tid也就是thread的id,每个thread负责数组一个数的操作,所以将10个循环操作拆分成了十个线程同时搞定。这里的kernel函数也就是可以同时并发执行,而里面的tid的数值是不一样的。

第五章 线程协作
GPU逻辑结构
这章就开始介绍线程块和网格的相关知识了,也就是<<<>>>这里面数字的含义。首先讲一下什么叫线程块,顾名思义就是线程组成的块咯。GPU的逻辑结构如下图所示: 
 
这个图来自NVIDIA官方文档,其中CTA就是线程块,Grid就是线程块组成的网格,每个线程块里有若干线程束warp,然后线程束内有最小的单位线程(文档里会称其为lanes,翻译成束内线程)。 
基础知识稍微介绍一下,就开始介绍本章的内容了,本章的内容主要基于以下这个事实:

我们注意到硬件将线程块的数量限制为不超过65535.同样,对于启动核函数每个线程块中的线程数量,硬件也进行了限制。

由于这种限制的存在,我们就需要一些更复杂的组合来操作更大长度的数组,而不仅仅是使用threadIdx这种naive的东西了。 
我们提供了以下的kernel来操作比较长的数组:

__global__ void add(int *a, int *b, int *c) {
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    while (tid < N) {
        c[tid] = a[tid] + b[tid];
        tid += blockDim.x * gridDim.x;
    }
}
1
2
3
4
5
6
7
嗯,理解透了int tid = threadIdx.x + blockIdx.x * blockDim.x;这句话,这章就算胜利完工了。首先,为啥是x,那有没有y,z呢,答案是肯定的,但是这里(对,就这本书里),用不上。其实线程块和网格都并不是只有一维,线程块其实有三个维度,而网格也有两个维度。因此存在.x的现象。当然我们不用管这些事,就当做它们只有一维好了。那就看下面这个图: 


这就是只有一维的线程网格。其中,threadIdx.x就是每个线程在各自线程块中的编号,也就是图中的thread 0,thread 1。但是问题在于,每个block中都有thread 0,但是想让这不同的thread 0操作不同的位置应该怎么办。引入了blockIdx.x,这个就表示了线程块的标号,有了线程块的标号,再乘上每个线程块中含有线程的数量blockDim.x,就可以给每个线程赋予依次递增的标号了,程序猿们就可以操作比较长的数组下标了。

但是问题又来了,要是数组实在太大,我用上所有的线程都没办法一一对应咋办,这里就用tid += blockDim.x * gridDim.x;这句话来让一个线程操作很好几个下标。具体是怎么实现的呢,就是在处理过当前的tid位置后,让tid增加所以线程的数量,blockDim.x是一块中线程总数,而gridDim.x则是一个网格中所有块的数量,这样乘起来就是所有线程的数量了。

至此,线程协作也讲完了。再上一个更直观的图: 


共享内存
共享内存是个好东西,它只能在block内部使用,访问速度巨快无比,好像是从离运算器最近的L1 cache中分割了一部分出来给的共享内存,因此巨快。所以我们要把这玩意用起来。 
这里的例子是点积的例子,就是: 
 
最后得到一个和。主要思想如下:

前一半加后一半: 

要同步,别浪
把最后的并行度小的工作交给CPU 
具体代码是酱婶儿的:
__global__ void dot(float *a, float *b, float *c) {
    //建立一个thread数量大小的共享内存数组
    __shared__ float cache[threadsPerBlock];
    int tid = threadIdx.x + blockIdx.x * blockDim.x;
    int cacheIndex = threadIdx.x;
    float temp = 0;
    while (tid < N) {
        temp += a[tid] * b[tid];
        tid += blockDim.x * gridDim.x;
    }
    //把算出的数存到cache里
    cache[cacheIndex] = temp;
    //这里的同步,就是说所有的thread都要达到这里之后程序才会继续运行
    __syncthreads();
    //下面的代码必须保证线程数量的2的指数,否则总除2会炸的
    int i = blockDim.x / 2;
    while (i != 0) {
        if (cacheIndex < i)
            cache[cacheIndex] += cache[cacheIndex + i];
        //这里这个同步保证了0号线程不要一次浪到底就退出执行了,一定要等到都算好才行
        __syncthreads();
        i /= 2;
    }
    if (cacheIndex == 0)
        c[blockIdx.x] = cache[0];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
其中这个数组c其实只是所以结果中的一部分,最后会返回block数量个c,然后由cpu执行最后的加法就好了。

第九章 原子性操作
原子性操作,就是,像操作系统的PV操作一样,同时只能有一个线程进行。好处自然是不会产生同时读写造成的错误,坏处显而易见是增加了程序运行的时间。

计算直方图
原理:假设我们要统计数据范围是[0,255],因此我们定义一个unsigned int histo[256]数组,然后我们的数据是data[N],我们遍历data数组,然后histo[data[i]]++,就可以在最后计算出直方图了。这里我们引入了原子操作

__global__ void histo_kernel(unsigned char *buffer, long size,
        unsigned int *histo) {
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int stride = blockDim.x * gridDim.x;
    while (i < size) {
        atomicAdd(&(histo[buffer[i]]), 1);
        i += stride;
    }
}
1
2
3
4
5
6
7
8
9
这里的atomicAdd就是同时只能有一个线程操作,防止了其他线程的骚操作。但是,巨慢,书里说自从服用了这个,竟然比CPU慢四倍。因此我们需要别的。

升级版计算直方图
使用原子操作很慢的原因就在于,当数据量很大的时候,会同时有很多对于一个数据位的操作,这样操作就在排队,而这次,我们先规定线程块内部有256个线程(这个数字不一定),然后在线程内部定义一个临时的共享内存存储临时的直方图,然后最后再将这些临时的直方图加总。这样冲突的范围从全局的所有的线程,变成了线程块内的256个线程,而且由于也就256个数据位,这样造成的数据冲突会大大减小。具体见以下代码:

__global__ void histo_kernel(unsigned char *buffer, long size,
        unsigned int *histo) {
    __shared__ unsigned int temp[256];
    temp[threadIdx.x] = 0;
    //这里等待所有线程都初始化完成
    __syncthreads();
    int i = threadIdx.x + blockIdx.x * blockDim.x;
    int offset = blockDim.x * gridDim.x;
    while (i < size) {
        atomicAdd(&temp[buffer[i]], 1);
        i += offset;
    }
    __syncthreads();
    //等待所有线程完成计算,讲临时的内容加总到总的直方图中
    atomicAdd(&(histo[threadIdx.x]), temp[threadIdx.x]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
第十章 流
页锁定内存 
这种内存就是在你申请之后,锁定到了主机内存里,它的物理地址就固定不变了。这样访问起来会让效率增加。
CUDA流 
流的概念就如同java里多线程的概念一样,你可以把不同的工作放入不同的流当中,这样可以并发执行一些操作,比如在内存复制的时候执行kernel: 
 
文后讲了一些优化的方法,但是亲测无效啊,可能是cuda对于流的支持方式变了,关于流的知识会在以后的博文里再提及。
十一章 多GPU
这章主要看了是第一节零拷贝内存,也十分好理解就是,在CPU上开辟一片内存,而GPU可以直接访问而不用复制到GPU的显存里。至于和页锁定内存性能上的差距和区别,需要实验来验证

===================2017.7.30更新======================== 
在阅读代码时发现有三种函数前缀: 
(1)__host__ int foo(int a){}与C或者C++中的foo(int a){}相同,是由CPU调用,由CPU执行的函数 
(2)__global__ int foo(int a){}表示一个内核函数,是一组由GPU执行的并行计算任务,以foo<<>>(a)的形式或者driver API的形式调用。目前global函数必须由CPU调用,并将并行计算任务发射到GPU的任务调用单元。随着GPU可编程能力的进一步提高,未来可能可以由GPU调用。 
(3)__device__ int foo(int a){}则表示一个由GPU中一个线程调用的函数。由于Tesla架构的GPU允许线程调用函数,因此实际上是将__device__ 函数以__inline形式展开后直接编译到二进制代码中实现的,并不是真正的函数。

具体来说,device前缀定义的函数只能在GPU上执行,所以device修饰的函数里面不能调用一般常见的函数;global前缀,CUDA允许能够在CPU,GPU两个设备上运行,但是也不能运行CPU里常见的函数;host前缀修饰的事普通函数,默认缺省,可以调用普通函数。
--------------------- 
作者:FishSeeker 
来源:优快云 
原文:https://blog.youkuaiyun.com/fishseeker/article/details/75093166 
版权声明:本文为博主原创文章,转载请附上博文链接!

### 关于9216个CUDA核心的规格与性能 #### CUDA Core 数量背景 GPU 中的 CUDA 核心数量是衡量其并行处理能力和计算性能的重要指标之一。根据已知的信息,GeForce RTX 3070 Ti 笔记本电脑 GPU 配备了总计 **5888** 个 CUDA 核心[^1],而更高端的显卡如 GeForce RTX 2080 Ti 则拥有 **4352** 个 CUDA 核心。相比之下,提到的 **9216** 个 CUDA 核心通常会出现在更高阶的数据中心级或工作站级别的 GPU 上。 #### 数据中心级别 GPUCUDA 核心分布 以 NVIDIA A100 Tensor Core GPU 为例,该型号基于 Ampere 架构设计,具备高达 **9216** 个 CUDA 核心以及额外的张量核心支持加速 AI 和 HPC 工作负载。这些核心分布在多个流式多处理器(Streaming Multiprocessors, SMs)上,在单精度浮点运算方面表现出卓越的能力。具体来说: - 对于每个多处理器单元而言,它包含了固定数目的 CUDA 核心——例如在 Ampere 架构下为 **128** 个 CUDA 核心/SM。 - 整体架构通过增加 SM 单元总数来实现更高的整体 CUDA 核心计数,从而提升全局吞吐率和效率。 #### 统一缓存体系结构的影响 值得注意的是,随着架构的进步,现代 GPU 不仅增加了 CUDA 核心数目还优化了存储子系统的布局方式。比如,在 Ampere 架构中引入了一种新的统一缓存模型,其中 L1 缓存与共享内存被整合成单一资源池以便灵活分配给不同类型的访问请求[^3]。这种改进有助于减少延迟并提高带宽利用率,进而增强应用程序的整体表现特别是那些依赖频繁数据交换的任务场景。 #### 性能考量因素 除了单纯的 CUDA 核心数量之外,实际应用中的性能还会受到其他多种要素制约包括但不限于时钟频率、位宽、显存速度等因素共同作用决定最终成果展示情况如何呈现出来。因此即使两款产品可能具有相同的理论最大算力数值但由于上述差异也可能导致实测成绩存在显著区别。 ```python # 示例 Python 脚本来估算 FLOPs 峰值 def calculate_flops(cuda_cores, core_clock_mhz): return cuda_cores * core_clock_mhz * 2 # 每周期两次操作 FP32 a100_core_count = 9216 base_clock_speed = 1410 # MHz as an example value for base clock speed of A100 peak_tflops_fp32 = round((calculate_flops(a100_core_count, base_clock_speed)) / pow(10, 12), 2) print(f"A100 Peak TFLOPS (FP32): {peak_tflops_fp32} TFLOPS") ``` 以上代码片段展示了如何依据基础参数粗略估计某款特定硬件所能达到的最大浮点运算次数(TFLOPS)。此方法适用于初步对比分析不同类型设备间潜在差距大小关系。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值