我的 GPU 比它替换掉的 CPU 还要慢

我曾经把一项繁重的计算从 CPU 搬到 GPU 上,满以为它会飞起来。结果它却爬行——比它本该替换掉的那段普通代码慢得离谱。我以为买到了速度,却不知怎的装上了一台减速器。

硬件并不是瓶颈。一块现代 GPU 是你能装进电脑里最快的东西之一。问题在于,我交给它的工作,其形态恰恰是它几乎独一无二地不擅长的那一类,然后我还对它跑不动这件事感到惊讶。

那次成了降级的升级

我的预期既简单,而且我以为也很稳妥:GPU 很快,我的任务很慢,所以把任务搬到 GPU 上理应让它变快。这就是关于加速如何运作的那套民间版本说法,而它错得很具体,也很有教育意义。

当我测量这个“加速过”的版本时,它不是慢了一点。它慢了一大截——那种结果会告诉你,问题不是少做了某项优化,而是一个根本性的不匹配。我正在做的事情,其形态中有某种东西在主动地跟硬件对着干。

“把它放到 GPU 上”到底意味着什么

我真正做的是这样。我有一个现成的循环:取一份工作,把它算出来,然后转向下一份,如此反复成千上万次。为了“用上 GPU”,我只是简单地让每一份单独的工作在 GPU 上运行——而把循环原封不动地留在了原地。

于是成千上万次接连不断地,我的程序转向 GPU,递给它一个小任务,等着答案,再转回来取下一个。掌控全局的仍然是那个循环。GPU 不过是一个我打电话叫来的、非常快的承包商,而我为每一颗想钉的钉子都单独给他打一通电话。

隐藏的成本:光是开口问就要花钱

每次你把一份工作交给 GPU,为它做准备并把它派发出去都有一笔固定的成本——单独看很小,付一次几乎察觉不到。而我付了成千上万次。当我仔细一看,大部分运行时间根本不是在计算。它是开销:GPU 闲坐着,等着别人告诉它该做什么,而我的程序则把时间花在一个接一个地问一些微不足道的问题上。

我搭建出来的流程,几乎完全就是“问”这个动作本身,而真正的工作只是个可以忽略不计的零头。CPU 版本本来就慢,但至少它一直在忙。GPU 版本之所以慢,是因为它一生中大部分时间都在等待。

GPU 是一辆公交车,不是一辆出租车

最终把问题点破的那个心智模型是:GPU 不是把一件事做得更快的方式。它是一次性把数量极其庞大的同一件事做完的方式。它是一辆五十座的公交车,不是一辆跑车。

每件事都单独调用它一次,就好比让那辆公交车在城里来回开,每趟只载一名乘客。这辆车确实快。而你的吞吐量惨不忍睹——之所以惨不忍睹,恰恰是因为你拿一辆为载满人群而造的车去一个一个地运人。错不在车。错在调度。

修复办法:别再循环,开始批处理

修复办法不是一个更快的内核或一块更好的卡。而是重构那个循环。我没有再用“对每一份工作,把它发给 GPU”,而是把工作重塑成 GPU 可以一次接收许多份并一起处理的形态——把成千上万次微小的派发,变成寥寥几次大规模的派发。我原先一遍又一遍付出的那笔开销,坍缩成了我只需付几次而非成千上万次的东西。

这就是人们所说的“向量化”或批处理的一种常见形式:把许多相似的操作一起表达出来,而不是表达成一长串小步骤组成的序列。它往往就是一块让你的 CPU 蒙羞的 GPU,与一块被你的 CPU 弄得蒙羞的 GPU 之间的差别,而两者之间的鸿沟可以大到惊人。

为什么这正是这套硬件的全部意义所在

我花了一段时间才真正消化,为什么结构如此重要,而不是把它当成一个调优的细节。GPU 在这里的优势并不来自任何单一操作本身有多快。它来自同时进行数量庞大的相似操作。那种并行性才是它的全部价值主张。如果你的代码无法把它的工作表达成“同时把所有这些都做了”,那并行性就没有任何可以抓住的东西——而一块串行地、一次只做一件事的 GPU,不过是一台昂贵、别扭、形态糟糕的 CPU。

所以“把它放到 GPU 上”从来都不是那条真正的指令。真正的指令是“把工作重塑成可以一次性完成的形态”。硬件能跑多快,从来只取决于问题的形态允许它跑多快。

工具救不了错误的形态

这个教训的适用范围远不止 GPU。如果你的计算结构在跟硬件对着干,那么去够更快的硬件几乎无济于事。对于这类工作,性能更多地存在于计算的形态之中——它如何被组织、批处理与表达——而不是存在于运行它的那个东西的名义速度之中。你可以把一个慢算法放到一台快机器上,得到一场迅速的失望。

我把那次减速记在心里,提醒自己“我把它变快了”和“我把它搬到了更快的硬件上”不是同一句话。前者关乎理解工作本身。后者关乎指望那块硅片替你去理解它。它不会的。

—— 没有信号,没有收益,不构成投资建议。