关于实验进度追不上截稿日被迫使用GPU做异构计算这件事(1)

前情提要

当我确定要去投一个会议的稿件的时候，距离截稿日只有一周的时间了。但是原先取得的数据没有办法直接用，只能从头开始设计程序获取数据，值得庆幸的是好在核心的计算模块可以复用。我在刚开始的第一天还是很乐观的，预估用两天时间获取实验数据，三天写文章，剩下的时间里修改，赶在截稿前就能提交。

但是实际情况并不尽人意。由于过往的经验，我错误的估计了实验的运行时间。我原先的实验是在每轮计算 4 个大规模复数矩阵，但是这次实验为了得到更详细的结果，每轮需要计算的矩阵暴涨到 42 个。更糟糕的是，我原先预计跑 28 轮计算，而每一轮的每一个矩阵的规模都会是上一轮的两倍。第 28 轮时每个矩阵的数据量会达到大约 1GiB，而为了对这个矩阵做处理还需要额外存储三个同等规模的矩阵（为了方便说明，让我们把之前的那个矩阵称为核心矩阵，这三个矩阵称为辅助矩阵）！也就说光是处理一个核心矩阵就需要 4GiB 的空间，更别提中间产生的一些中间量数据，而这样的计算我需要做 42 次（这还只是第 28 轮单轮计算过程！）......

我没有办法把这样的计算量压在一颗 CPU 核心上，除非我想算到天荒地老...... 我最早的方案是用多核并行计算。因为每一个核心矩阵的计算是彼此独立的，非常适合并行计算的方案，这在我最早的实验里是对的，因为我每轮涉及到的只有 4 个核心矩阵，哪怕加上中间量，也不会超过我有的 64GB 内存，我不需要考虑其他的方案，只要在 4 个 CPU 核心同时启动，只需要大约 2 个小时就能给我结果。

但当每轮的核心矩阵数达到 42 个，这样的方案就行不通了！首先为了缩短计算时间，我需要更大的并行数，而当我把并行数提高到 16 的时候，第 28 轮的总占用内存量达到了 100GB 以上！我不得已为此额外添加了内存，但是事实上，哪怕我添加了足够的内存，这样的计算量和数据交换量对于一颗普通的家用 CPU 而言还是太大了，第 20 轮以后的每轮的计算时间都是用小时作为单位，而且还会呈指数级增长...... 更不用说，如果在途中出现了意外退出或者输出结果不如人意，我免不了回头重新审视程序设计，更有可能需要从头再跑几轮......

我真的很希望时间能倒退几个月！这样我可以好好设计我的实验方案，再不济我也有足够的时间去等待整个计算过程。但是现在哪怕我能两天写完文章，我也只有四到五天时间了！我没有办法去做一些很复杂的优化设计，因为我没有时间去反复调试程序，现在摆在我面前的只有一条路： 去找一种简单粗暴的方案，能够复用大部分程序，不需要对整体框架做出大范围修改，同时要能够想方设法把指数级增长的计算时间压下去。

让我们看一下这个问题的核心思路：每一轮核心矩阵的规模都是翻倍的，为了能够让计算时间保持线性增长，我们需要让每个矩阵的计算时间也保持线性，所以最简单的方法，如果矩阵规模翻倍，那就让计算模块也翻倍。

很遗憾我的 CPU 没有办法做到这一点，他只有 8 个核心！但是我们也不是没有其他的手段，想一想那个著名的视频，在流言终结者里有一集，他们用彩弹枪形象地再现了 CPU 和 GPU 的主要区别。如果我们没有办法让我们的 CPU 的核心满足我们的指数增长的需求，那么把矩阵相关的计算交给并行计算能力更强的 GPU 就是一个很明智的选择，GPU 简直就是为此而生的！

异构计算方案

我们把结合不同计算单元进行计算的方法称作异构计算。最典型的例子就是 CPU 和 GPU 的结合。是的，虽然这个词听上去非常的高级，但是它广泛应用在许多领域，其中最有亲切感的可能就是游戏。CPU 和 GPU 的结合是个热门领域，正因为这一方案的广泛性，我们有很多简单好用的工具来支持我们将单一 CPU 架构的程序转换为结合 GPU 的程序，这些工具里最有代表性的是 NVIDIA 公司针对自己 GPU 产品推出的 CUDA，还有对多个厂商的 GPU 都提供了支持的 OpenCL。这些年随着对 GPU 计算的重视，AMD 也推出了他们家的 ROCm 方案，但是这个工具的社区和生态还有待提高。我尽管很愿意去尝试新的技术，但是现阶段我得先完成我的实验！

我家里的电脑和研究室的电脑使用的都是 NVIDIA 的 GPU，所以我最早考虑的就是使用 CUDA 的方案。CUDA 是一个非常成熟的工具，也有人针对它开发了许多好用的库。尤其是近些年随着 GPU 在 AI 领域的使用，涌现了很多傻瓜式调用 CUDA 的库，甚至有不需要对原有代码进行修改，就能直接将计算模块下放到 GPU 的手段，遗憾的是我目前的程序并不适用。我最后用的是一个叫做 CuPy 的 Python 库，因为他和 NumPy 有很高的相似性，你也可以认为他是 NumPy 的 GPU 版本的简单实现。这一点对我来说很重要，因为涉及到矩阵计算，为了提高计算速度，我在程序里大量的使用了 NumPy 的计算方法和数据类型，我并不想在将计算模块下放 (我指 offload 这个词) 给 GPU 时还需要对数据进行过多的整形，这不仅仅只是因为我需要大量修改我的代码，而且这也会产生许多额外的计算开销。

CuPy 的用法很简单，我用了大概一小时左右就成功修改并跑通了程序，在这里我无意过多地讲述代码实现的细节，如果有时间的话我可能会单独写一篇文章来聊一聊这些（当然最好是在我对 CUDA 有更深的理解以后）。但是在这个时间点我却并没有得到我想要的结果，现实总是比人们想象到的更为复杂。

首当其冲的依然是数据传输问题，尽管现代 GPU 采用的都是高速存储，但是如果反复在 CPU 和 GPU 之间交换数据，那么大量的时间将会被消耗在这上面，计算部分必须一直等待数据就位，而集齐了数据以后又算不了多久，马上又要把结果传输出去了。这并不是使用 pipeline 结构或者将数据分块传输就能解决的问题。因为在最初的设计里，GPU 只需要负责对每个矩阵要素单独进行的几个简单计算，你可以简单理解为我们只是将每个要素都单独乘以 2，所以 pipeline 一类的计算结构并不十分适用，同时，从一开始我们的计算就不需要等待整个矩阵的传输，计算过程也不会造成巨大的延迟，一切的问题根源就在于这里，我们的计算部分足够快，所以性能瓶颈就落在了数据传输上。

在不更改原有设计的基础上这一点很难改变，我不得已对计算结构进行了调整，将后续很多放在 CPU 上的数据处理部分也下放给了 GPU。这个修改是很有效的，我们加大了 GPU 的计算负载，同时尽可能减少 CPU-GPU 之间频繁的数据传输，这使得我的 GPU 的最高计算负载到了 80%。

然而在这里出现了另外一个问题，现在我的 GPU 不再仅仅对每个矩阵元素进行处理，而是需要保存处理后的矩阵，再对其整体进行后续的处理，就像最开始提到的一样，我们会需要非常巨大的矩阵，这种时候我们的 GPU 内存就开始不足了，很遗憾我的 GPU 的内存只有 4GB，尽管通过 SWAP 我们可以共享部分的系统内存，但是这部分就会遭遇频繁数据传输的问题... 我当时能想到的手段，是通过分割矩阵，只给 GPU 部分矩阵来获取部分的结果，再将其传到 CPU 后进行合并，这比原先会好一些，但在 24 轮之后依然无可避免的遭遇了数据传输带来的瓶颈，硬件带来的限制比想象的更严重一些。

由于时间上的限制，我迫不得已放弃了原先运行 28 轮的方案，而是在尽可能保证实验结果的可验证性和完整性的基础上，降低了计算次数，选择了运行 25 轮的方案，到这一步我成功在 8 小时内得到了所有我需要的结果，并赶在截止前完成了我的文章（感谢我的老师！）

我依然在继续进行程序的优化，接下来的方向有两个，一个是通过 MPI 等方案分散计算压力到多个机器，但预想这依然有数据传输瓶颈等着我解决，另一个是使用 FPGA 做数据计算后传递给主机，资源够多的情况下这种方案比 GPU 在数据传输和延迟方面更有优势，但是开发成本也有很大的提升，我还在犹豫这种方案，除非某一天我发现我确实需要定制化硬件来解决一些传统方案所无法解决的问题。