一个程序要运行,离不开数据和指令,而在运行过程中存储数据/指令的主要媒介就是内存,CPU 程序如此,GPU 程序同样。
只不过 GPU 要运行的指令,是在 CPU 端编译好发给它的,GPU 运行的结果,也需要返回 CPU 侧,以便被读取(参考 GPU 构成和工作原理 - 简介)。
因此,在 Discrete GPU 模型中,要运行一个完整的 GPU 程序,既需要 CPU 侧的内存(在 CUDA 的术语中被称为 host memory),也需要 GPU 侧的内存(被称为 device memory)。
![d5d1fbf86a2949803eee87cd64d91410.jpeg](https://i-blog.csdnimg.cn/blog_migrate/3b88fe5cc3ec63e563de3484817de6d0.jpeg)
碧涧流泉
咱们先来看下使用早期的 CUDA,如何完成一个基础的 GPU 程序。
![2503b74319fdfa6f43260a7e1750ee64.jpeg](https://i-blog.csdnimg.cn/blog_migrate/16d55863b95940bd012984c982435c36.jpeg)
首先,调用 cudaMalloc 在 device memory 上分配一块区域,调用 cudaMallocHost 在 host memory 上也分配同样大小的一段空间,然后初始化数据,并将初始化后的数据从 host memory 拷贝到 device memory。
![315c7b0b2b71969e5c41da09b577045a.jpeg](https://i-blog.csdnimg.cn/blog_migrate/35f47f5d19e4b7514ccef1a401b6a357.jpeg)
这真的是一段 GPU 程序么,怎么跟以前见过的 C/C++ 写的 CPU 代码那么像。先别急,接下来,就到了见证差异的时刻。
"kernel <<< ... >>>" 表明这是在 GPU 上运行的,注意此 kernel 非彼 kernel,它是 Nvidia 家的一个术语,表示一个可以在 GPU 上执行的 function,通常使用 "GPU kernel",来和我们熟知的 Linux kernel 区分开。
为了降低学习的难度曲线,CUDA 的语法和标准 C 语言很像(既是对开发者友好,也对自己的推广有利不是),至于 "<<<...>>>" 这种,你就理解为是一种 C/C++ 的扩展吧。
好了,等 GPU kernel 执行完,还得把处理后的数据拷贝回 CPU 侧,并返回结果。
可见,整个过程中有两次 copy。那能不能省掉?
1. 为什么数据要由 CPU 来初始化,GPU 自己不能初始化么?可以,对应的编程模型在 CUDA 里面叫做 Dynamic Parallelism,由 parent kernel 去唤起 child kernel(自 CUDA 5 开始支持)。
![cd11f8df3d50ecd9467ed5fa0051d484.jpeg](https://i-blog.csdnimg.cn/blog_migrate/614df94e452c89d7d72a8b0526a23f25.jpeg)
2. 如果还是让 CPU 来初始化,那 GPU 直接访问这段 host memory 不行么?也是可以的,但通常只有 Page-Locked 的 host memory 可以被 GPU 建立映射,直接访问【注-1】。
而且,GPU 访问自己的 device memory,不仅带宽更给力、延迟更低(不用跨越 PCIe),还能利用 cache。对 CPU 来说,也是这样。所以从 locality 的角度,一般还是倾向于访问 local memory。
这让笔者想起了 Linux 里面用户态和内核态共享数据的两种方式:read/write 和 mmap。可能很多人都觉得 mmap 不用拷贝,性能应该更好,但正如这篇文章讲到的:随着硬件的发展,内存拷贝消耗的时间已大大降低,有些情况下,mmap 的性能反倒比不过 read/write。
雨过天青
随着硬件升级,软件也在不断迭代。时间来到 2014 年,诞生自 2006 年的 CUDA 迎来了它的第 6 个版本。在 CUDA 6 里,一个叫做 "Unified Memory" 的概念被提了出来(以下简称 UMA)。
前面的那段示例程序,可以说是一丝不苟,但这么套路的东西,还要开发者手动指定内存分配的物理位置,不是有点麻烦么。而且,是人就会犯错,手动的出错概率更高。
这个 UMA 就是“自动”把内存置放的工作接管了,你只需要调用 cudaMallocManaged 这个 API,不用管内存是在哪里分的,也不用去做拷贝,UMA 都会帮你打理好。
和传统的 cudaMalloc/cudaMallocHost 不同,通过 cudaMallocManaged 申请的内存(称为 managed memory)不是立即满足的,而是等真的用到时才分配物理内存,即 demand paging(此处可类比 Linux 中 kmalloc 和 malloc/vmalloc 的区别)。
伴随 CUDA 6 的是 Kepler 架构,基于这种架构的 GPU 不能处理 page fault,对于 demand paging 的支持有限。而到了 CUDA 8 时代,硬件已升级为能够支持 page fault 的 Pascal 架构,UMA 才步入成熟。所以有句话说的好啊:
Computing is a not a chip problem, it is a software and chip problem.
不能处理 page fault 主要是 GPU thread 不能暂停,因为访问 absent page 时,GPU 需要 pause 正在执行的 thread ,把页面弄回 device memory。
而如果 GPU 支持 page fault,就没有这样的限制,暂时不用的 GPU 页面甚至可以 evict 到 host memory,下次用到时再换回。
对于 page fault 存在的开销,也可通过 Prefetching 的方式减小(思路类似于 Linux 中对 Page Cache 的 Readahead)。
![a287c925f7e01cdb3d18fd4245e1758b.jpeg](https://i-blog.csdnimg.cn/blog_migrate/49d224bdb5f4d688f44ee88ef2e865b5.jpeg)
远山如黛
一个 CUDA 程序,有一部分是由 CPU 完成的(比如初始化和返回结果,下图蓝色部分所示),有一部分是由 GPU 完成的(比如计算和渲染,下图绿色部分所示),里面的变量 x, y 通过 CPU/GPU 各自的虚拟地址(VA)访问,如果这两个 VA 能保持一致,将带来编程的方便。
"single-pointer-to-data",也是 UMA 的一个重要组成部分。
![75ea4e5aca431bbb038d10d566055f60.jpeg](https://i-blog.csdnimg.cn/blog_migrate/fc2a1e8784ead424c2d28699f579f57e.jpeg)
这就要求 GPU 的虚拟地址空间和 CPU 匹配,x86-64 的 CPU 一般是 48 位吧,Pascal 架构的 GPU 是 49 位,完全没问题。
![5845c0f3079eb4b9c91f3b6d01e55749.png](https://i-blog.csdnimg.cn/blog_migrate/179625f7a4a8032ec61025c6d8790fa4.png)
VA 相同,但 PA 可能不同。CPU 的用户态程序虽然和 GPU 使用相同的 address space,但有各自独立的 per-process 页表。
![949d595ae5ac44978db555ace000418d.jpeg](https://i-blog.csdnimg.cn/blog_migrate/26865ad58fdcfafc9dd804dbe60bb1ae.jpeg)
总之,不管是 demand paging 还是 share virtual address,UMA 的目标都是简化 GPU 程序的开发,让 developer 可以更专注于算法的实现,而不用操心底层内存管理的细节,是一种更加 intelligent 的编程框架。
注-1:在 Linux 中,Page-Locked (pinned) memory 不可被移动或换出到 disk,如果不是 pinned 页面,那可能就需要 CPU 帮忙换入(通过 CPU 侧的 page fault)。pinned 页面不宜过多,否则会影响系统腾出可用内存。
说明:本文的三个段落标题源自设计师苏超在《一席》的演讲《中国美色》。青草是绿的,青天是蓝的,“青丝一缕”则是黑的,青色到底是什么颜色?看山的时候,如果这个山上都是植被的话,近处的山是绿色的,再远一点就开始泛蓝了。如果阴天,光线更加暗一点,你看远处的山,就是一个青黑色的剪影。
“碧涧流泉”象征数据的移动,“雨过天青”寓意一种突破,“远山如黛”取层层递进之意。