合理利用计算机资源

2021-08-08 10:54:33

我们办公桌上的电脑速度快得令人无法理解。它们在一秒钟内执行的操作比人类一百年执行的操作还多。我们生活在一个 CPU 每秒可以执行数十亿条指令的时代,如果考虑到多核,则可以执行数百亿条指令,可以以每秒数百 GB 的速度将数据传输到 CPU 的内存,以及支持流式读取的磁盘每秒千兆字节。这个硬件速度极快的时代,也是程序从 SSD 或 NVMe 磁盘启动需要几十秒的时代;臃肿的 Web 应用程序,即使在宽带连接上,也需要几秒钟才能显示一个简单的列表;以我们期望的千分之一速度处理数据的程序。软件滞后且缓慢——而且情况几乎没有改善的迹象。这是为什么?我相信主要原因是我们大多数在 2000 年之后开始编程的人从未学会如何合理使用我们可以使用的计算资源。事实上,我们的大部分培训都教会了我们忽视电脑!尽管我们的工作表面上是创建程序,让用户可以用他们的计算机做事,但我们更强调开发过程和面向开发的关注点,而不是最终的用户产品。 SICP 中有一句我认为是对问题的一个很好的总结:“程序必须是为了人们阅读而编写的,而只是顺便让机器执行。”许多程序员发现这句话很明智且鼓舞人心,但用户对阅读程序不感兴趣,他们对快速执行程序感兴趣。如果我们以一种“偶然”可执行的方式编写程序,我们就无法使程序运行得很快。计算机不是一个可以抽象化和忽略的实现细节——它是解决方案的一个组成部分。一个在设计中没有为目标机器留出空间的程序将不可避免地比一个程序运行得慢。人们迁移到更快的程序是因为更快的程序允许用户做更多的事情。看看过去的例子:最初的基于 Python 的 bittorrent 客户端很快被速度更快的 uTorrent 取代; Subversion 失去了作为主要 VCS 的地位,很大程度上是因为 Git 中的每个操作都快得多;改进的 grep 实用程序 ack 是用 Perl 编写的,但随着速度更快的 silversurfer 和 ripgrep 的流行度越来越低;基于 Electron 的编辑器 Atom 几乎被 VSCode 取代,VSCode 也是基于 Electron 的,但速度更快; Chrome 之所以成为浏览器之王,很大程度上是因为它比 Firefox 和 Internet Explorer 快得多。最快的选择最终获胜。如果竞争对手出现并且速度提高了十倍,您的项目会存活下来吗?反对在程序设计阶段考虑计算机的一个常见论点是“过早优化是万恶之源”。缓存友好性、分支预测和并行性等主题被标记为“优化”,但实际上它们应该被标记为“合理使用”。我们用户的计算机有资源,我们应该以他们使用这些资源的方式设计我们的程序。以 CPU 缓存为例。一个简单的程序显示了良好的缓存使用对性能的巨大影响是对矩阵的元素逐行和逐列求和(这是一个 Rust Playground 链接,演示了这一点)。在这个例子中,逐行比逐列快 12-16 倍,即使它们具有相同的 Big-O 复杂度。性能的差异不是由于抽象的计算机科学因素,而是由于一个非常具体的因素:更好地利用 CPU 缓存。逐行方法在缓存中带来 16 个浮点数,并在返回内存之前使用它们;相比之下,逐列方法也带来了 16 个浮点数,但在返回 RAM 之前只使用其中一个,留下 15 个未使用的值——这是 94% 的浪费。关于这个例子,我们应该注意一些事情。首先,编译器没有为我们优化代码。我们经常听到程序员无法击败优化编译器,但在这种情况下我们做到了。我们只需要更改循环的顺序,但是使用 LLVM 后端的 Rust 编译器没有这样做。也许著名的足够聪明的编译器会,但在可预见的未来,以一种可以有效处理的方式排列数据将不是编译器的工作——而是我们的工作。其次,如果在我们的解决方案中不考虑实际硬件,我们就无法获得这种加速。我们不会为抽象的、理想化的或虚构的机器编写代码:我们编写在我们用户的 CPU 上运行的代码——Intel、AMD、Apple 等。这些 CPU 有缓存,编写与硬件而不是反对它。第三,16 倍的加速可能只是复杂性理论中的一个常数因素,但对用户来说,这可能是程序使用愉快和极度令人沮丧之间的区别。或者小额 AWS 账单和大额 AWS 账单之间的差异。最后,我们不应该认为这是一种优化:我们正在利用那里的资源。用户支付了他们 CPU 的全价,让我们尝试给他们物有所值。

在我看来,优化将尝试在可以容纳 16 个的缓存行中打包 17 个浮点数。在项目开始时这样做确实为时过早。我希望很多人会想到那句古老的格言“让它工作,让它正确,让它快速”,并认为在我们第一次编写程序时将机器考虑在内是专注于“让它快速”,然后我们才开始“让它起作用”。我明白这一点,但是如果我们按顺序做事情,让我们的程序工作然后使它正确,当需要加快速度时,我们经常意识到我们的设计对我们不利,我们必须撤消/重做很多我们所做的工作是为了使程序有效并使其正确。例如,我们可能以面向行的方式排列我们的数据,即结构数组,我们意识到为了加速我们的程序,我们需要面向列的存储,即数组结构。将程序从一种形式更改为另一种形式是一项重大的重新架构项目,如果截止日期临近,并且市场上的软件更慢,格言变成“让它工作,让它正确,梦想让它变得更快”。那么我们如何编写在现代机器上运行得相当快的代码呢?我们开始养成习惯,不仅要考虑我们编写的模型,还要考虑机制:表示数据结构需要多少字节?是否有很多指针会导致缓存未命中?数据的组织方式是否使分支预测经常正确?在多个线程之间分割工作会很容易吗?另一种方法是从批次和系统的角度开始思考。假设我们正在为一种编程语言编写一个分词器;如果我们将令牌视为完全独立的数据片段,则每个令牌都需要携带与令牌关联的字符串(例如,struct Token { tag: Tag, text: String, ... })。相反,如果我们将标记化视为一个系统并将标记视为该系统的依赖项,我们可以在标记生成器中创建一个字符串池,并且标记可以在池中为其关联的文本创建一个索引。这通过不必复制字符串来节省内存并使令牌更小,这意味着可以在一个缓存行中容纳更多。面向数据的设计是一种关注这些问题的编程方法。目前,大多数面向数据的设计从业者都在游戏行业工作,但这些见解在各个领域都很有价值。 (例如,Andy Kelly 在 Zig 0.8.0 发行说明中高度评价了面向数据的设计。)如果您从未接触过面向数据的设计,那可能会有点震惊——很多常见的编程的智慧(例如,鲍勃叔叔的 SOLID 原则)被冷酷而艰难的工程所回避和取代。它看起来很可怕而且与众不同,但如果我们要交付的软件速度不会比它慢几个数量级,这就是我们需要做的。