软件设计的哲学:第二十章 性能设计

到目前为止,软件设计的讨论都集中在复杂性上,我们的目标是使软件尽可能的简单和易懂。但是,如果您正在开发一个需要快速的系统,该怎么办呢?性能考虑应该如何影响设计过程?本章讨论如何在不牺牲干净设计的前提下实现高性能。最重要的思想仍然是简单性:简单性不仅改进了系统的设计,而且通常使系统运行得更快。

20.1 如何考虑性能

要解决的第一个问题是“在正常的开发过程中,您应该在多大程度上担心性能?”“如果你试图优化每条语句以获得最大的速度,就会降低开发速度,并产生大量不必要的复杂性。此外,许多“优化”实际上并不能提高性能。另一方面,如果您完全忽略了性能问题,那么很容易在整个代码中出现大量显著的效率低下;得到的系统很容易比需要的速度慢5 - 10倍。在这种“死于千刀万剐”的情况下,以后很难再回过头来改进性能,因为没有一个改进会有很大的影响。

最好的方法是介于这两个极端之间,即使用基本的性能知识来选择“自然有效”但又干净简单的设计替代方案。关键是要意识到哪些操作从根本上是昂贵的。以下是一些如今相对昂贵的操作例子:

  • 网络通信: 即使是在一个数据中心,一个双向消息交换可以10 - 50µs,成千上万的指令。广域往返可能需要10-100毫秒。
  • 从I/O到辅助存储器: 磁盘I/O操作通常需要5-10 ms,这是数百万次的指令时间。闪存µs需要10 - 100。新兴的非易失性记忆可能1µs一样快,但这仍然是大约2000指令。
  • 动态内存分配 (C中的malloc, c++或Java中的new)通常涉及分配、释放和垃圾收集的大量开销。
  • 缓存丢失: 从DRAM获取数据到片上处理器缓存需要几百次指令;在许多程序中,总体性能由缓存丢失和计算开销决定。

了解哪些东西比较昂贵的最佳方法是运行微基准测试(单独测量单个操作成本的小程序)。在RAMCloud项目中,我们创建了一个提供微基准测试框架的简单程序。创建这个框架花了几天时间,但是这个框架使得在5到10分钟内添加新的微基准成为可能。这让我们积累了几十个微基准。我们使用它们来了解在RAMCloud中使用的现有库的性能,并度量为RAMCloud编写的新类的性能。

一旦您对什么是昂贵的,什么是便宜的有了一个大致的概念,您就可以在任何可能的情况下使用这些信息来选择便宜的操作。 在许多情况下,更有效的方法与更慢的方法一样简单。例如,在存储使用键值查找的大型对象集合时,可以使用散列表或有序映射。这两种方法通常都可以在库包中获得,而且都很简单、易于使用。然而,哈希表的速度可以轻松提高5 - 10倍。因此,除非需要映射提供的排序属性,否则应该始终使用散列表。

另一个例子是,考虑在C或C++这样的语言中分配一个结构数组。有两种方法可以做到这一点。一种方法是数组保存指向结构的指针,在这种情况下,必须首先为数组分配空间,然后为每个单独的结构分配空间。将结构存储在数组本身中要有效得多,因此只需为所有内容分配一个大块。

如果提高效率的唯一方法是增加复杂性,那么选择就更加困难。如果更有效的设计只增加了少量的复杂性,并且复杂性是隐藏的,因此它不会影响任何接口,那么它可能是值得的(但是要注意:复杂性是递增的)。如果更快的设计增加了大量的实现复杂性,或者导致了更复杂的接口,那么最好从更简单的方法开始,然后在性能出现问题时进行优化。但是,如果您有明确的证据表明性能在特定情况下非常重要,那么您最好立即实现更快的方法。

在RAMCloud项目中,我们的总体目标之一是为通过数据中心网络访问存储系统的客户机提供尽可能低的延迟。因此,我们决定使用特殊的硬件进行联网,这使得RAMCloud可以绕过内核,直接与网络接口控制器通信来发送和接收数据包。尽管增加了复杂性,但我们还是做出了这个决定,因为我们从以前的度量中知道,基于内核的网络速度太慢,无法满足我们的需求。在RAMCloud系统的其余部分中,我们能够简单地进行设计;“正确”解决这个大问题使许多其他事情变得更容易。

通常,简单的代码比复杂的代码运行得更快。如果您已经定义了特殊情况和异常,那么就不需要代码来检查这些情况,并且系统运行得更快。深度类比浅层类更有效,因为它们为每个方法调用完成了更多的工作。浅层类会导致更多的层交叉,并且每个层交叉都会增加开销。

20.2 修改前的测量

但是假设您的系统仍然太慢,即使您已经按照上面的描述设计了它。人们很容易根据自己对什么是慢的直觉,匆忙地开始调整性能。不要这样做。程序员对性能的直觉是不可靠的。即使对于有经验的开发人员也是如此。如果您开始基于直觉进行更改,那么您将浪费时间在实际上并没有提高性能的事情上,并且可能会使系统在此过程中变得更加复杂。

在进行任何更改之前,请度量系统的现有行为。这有两个目的。首先,度量将确定性能调优将产生最大影响的位置。仅仅度量顶级系统性能是不够的。这可能会告诉你系统太慢了,但它不会告诉你原因。您需要更深入地度量,以详细地确定影响整体性能的因素;目标是确定系统当前花费大量时间的少数非常具体的地方,以及您有改进的想法的地方。度量的第二个目的是提供一个基线,这样您就可以在进行更改之后重新度量性能,以确保性能确实得到了改进。如果这些更改在性能上没有产生可度量的差异,那么就将它们取消(除非它们使系统变得更简单)。除非它提供了显著的加速,否则保持复杂性是没有意义的。

20.3 围绕关键路径进行设计

现在,让我们假设您已经仔细地分析了性能,并确定了一段足够慢到影响整个系统性能的代码。提高其性能的最佳方法是进行“基本的”更改,如引入缓存,或使用不同的算法方法(例如,平衡树与列表)。我们决定绕过RAMCloud中的网络通信内核,这是一个基本解决方案的例子。如果您可以确定一个基本的修复,那么您可以使用前面章节中讨论的设计技术来实现它。

不幸的是,有时会出现没有根本解决办法的情况。这就引出了本章的核心问题,即如何重新设计现有的代码段,使其运行得更快。这应该是你最后的选择,这种情况不应该经常发生,但是在某些情况下,它可以产生很大的影响。关键思想是围绕关键路径设计代码。

首先要问自己,在通常情况下,执行所需任务所需执行的最小代码量是多少。忽略任何现有的代码结构。假设您正在编写一个只实现关键路径的新方法,这是在最常见情况下必须执行的最小代码量。当前的代码可能混杂着特殊情况;在这个练习中忽略它们。当前代码可能在关键路径上通过多个方法调用;想象一下,您可以将所有相关的代码放在一个方法中。当前的代码还可以使用各种变量和数据结构;只考虑关键路径所需的数据,并假设对关键路径最方便的数据结构是什么。例如,将多个变量组合成一个值可能是有意义的。假设您可以完全重新设计系统,以最小化必须为关键路径执行的代码。让我们称这个代码为“理想代码”。

理想的代码可能会与现有的类结构发生冲突,而且可能不实际,但它提供了一个很好的目标:这代表了代码所能达到的最简单、最快速的目标。下一步是寻找一个新的设计,尽可能接近理想,同时仍然有一个干净的结构。您可以应用本书前几章中的所有设计思想,但是附加了保持理想代码(大部分)完整的约束。您可能需要向理想状态中添加一些额外的代码,以实现干净的抽象;例如,如果代码涉及到哈希表查找,则可以向通用哈希表类引入额外的方法调用。根据我的经验,我们几乎总能找到一种简洁而又接近理想的设计。

在这个过程中发生的最重要的事情之一是从关键路径中删除特殊情况。当代码运行缓慢时,通常是因为它必须处理各种情况,而代码的结构简化了对所有不同情况的处理。每个特殊情况都会以附加条件语句和/或方法调用的形式向关键路径添加少量代码。每一项添加都会使代码变慢一点。在重新设计性能时,尽量减少必须检查的特殊情况的数量。理想情况下,在开头有一个if语句,它用一个测试检测所有的特殊情况。在正常情况下,只需要进行这个测试,然后就可以执行关键路径,而不需要对特殊情况进行额外的测试。如果初始测试失败(这意味着发生了特殊情况),代码可以转移到关键路径之外的一个独立位置来处理它。对于特殊情况,性能并没有那么重要,所以您可以为了简单性而不是性能来构造特殊情况的代码。

20.4 一个示例:RAMCloud缓冲区

让我们考虑一个例子,在这个例子中,RAMCloud存储系统的缓冲区类被优化为为最常见的操作实现大约2倍的加速。

RAMCloud使用缓冲区对象来管理可变长度的内存数组,例如用于远程过程调用的请求和响应消息。缓冲区的设计目的是减少内存复制和动态存储分配带来的开销。缓冲区存储的似乎是一个字节的线性数组,但为了提高效率,它允许将底层存储划分为多个不连续的内存块,如图20.1所示。缓冲区是通过附加数据块来创建的。每个块要么是外部的,要么是内部的。如果一个块是外部的,它的存储属于调用者;缓冲区保持对该存储的引用。外部块通常用于大块,以避免内存拷贝。如果块是内部的,则缓冲区拥有块的存储;调用者提供的数据被复制到缓冲区的内部存储中。每个缓冲区都包含一个小的内置分配,这是一个可用来存储内部块的内存块。如果这个空间被耗尽,那么缓冲区将创建额外的分配,在缓冲区被销毁时必须释放这些分配。对于内存复制成本可以忽略的小块,内部块非常方便。图20.1显示了一个有5个块的缓冲区:第一个块是内部的,后面两个是外部的,最后两个块是内部的。

图20.1:一个Buffer对象使用一个内存块集合来存储一个线性字节数组。内部块由缓冲区拥有,在缓冲区被销毁时释放;外部块不属于缓冲区。

缓冲区类本身代表了一种“基本修复”,因为它消除了在没有它的情况下可能需要的昂贵内存副本。例如,在RAMCloud存储系统中组装包含短标头和大型对象内容的响应消息时,RAMCloud使用一个具有两个块的缓冲区。第一个块是包含头的内部块;第二个块是一个外部块,它引用RAMCloud存储系统中的对象内容。可以在缓冲区中收集响应,而不需要复制大型对象。

除了允许不连续块的基本方法之外,我们没有尝试优化原始实现中的缓冲区类的代码。然而,随着时间的推移,我们注意到缓冲区在越来越多的情况下被使用;例如,在执行每个远程过程调用期间至少创建四个缓冲区。最后,很明显,加速缓冲区的实现可能会对整个系统的性能产生显著的影响。我们决定看看能否改进缓冲区类的性能。

缓冲区最常见的操作是使用内部块为少量新数据分配空间。例如,在为请求和响应消息创建标题时就会发生这种情况。我们决定使用这个操作作为优化的关键路径。在最简单的情况下,可以通过扩大缓冲区中最后一个现有块来分配空间。但是,只有在最后一个现有块是内部的,并且在其分配中有足够的空间容纳新数据时,才有可能这样做。理想的代码将执行一次检查以确认简单方法是可行的,然后调整现有块的大小。

图20.2显示了关键路径的原始代码,它从方法Buffer::alloc开始。在最快的情况下,Buffer::alloc调用Buffer:: allocateAppend,它调用Buffer::Allocation::allocateAppend。从性能的角度来看,这段代码有两个问题。第一个问题是,许多特殊情况是单独检查的:

  • Buffer::allocateAppend检查缓冲区当前是否有任何分配。
  • 代码检查两次,看看当前分配是否有足够的空间容纳新数据:一次是在Buffer:: allocation::allocateAppend中,另一次是在Buffer::allocateAppend测试其返回值时。
  • Buffer::alloc测试Buffer::allocAppend的返回值,再次确认分配成功。

此外,与尝试直接展开最后一个块不同,代码分配新空间时不考虑最后一个块。然后Buffer::alloc检查该空间是否恰好与最后一个块相邻,在这种情况下,它将新空间与现有块合并。这会导致额外的检查。总的来说,这段代码测试了关键路径中的6个不同条件。

原始代码的第二个问题是它有太多层,所有层都很浅。这既是性能问题,也是设计问题。除了原始的Buffer::alloc调用外,关键路径还执行两个额外的方法调用。每个方法调用都需要额外的时间,而且每个调用的结果都必须由调用者进行检查,这会导致需要考虑更多的特殊情况。第7章讨论了当您从一个层传递到另一个层时,抽象通常应该如何变化,但是图20.2中的所有三个方法都具有相同的签名,并且它们提供了本质上相同的抽象;这是一个危险信号。:allocateAppend几乎是一个通过方法;它唯一的贡献是在需要时创建一个新的分配。额外的层使代码更慢,也更复杂。

为了解决这些问题,我们对缓冲区类进行了重构,使其设计以性能最关键的路径为中心。我们不仅考虑了上面的分配代码,还考虑了其他几种常见的执行路径,比如检索当前存储在缓冲区中的数据的总字节数。对于这些关键路径中的每一个,我们都试图确定在普通情况下必须执行的最小代码量。然后我们围绕这些关键路径设计了其他的类。我们还应用了本书中的设计原则来简化类。例如,我们消除了浅层并创建了更深层的内部抽象。重构类比原始版本(1476行代码,而原始版本是1886行)小20%。

图20.2:使用内部块在缓冲区末尾分配新空间的原始代码。

图20.3:在缓冲区的内部块中分配新空间的新代码。

图20.3显示了在缓冲区中分配内部空间的新关键路径。新代码不仅更快,而且更容易阅读,因为它避免了肤浅的抽象。整个路径在一个方法中处理,它使用一个测试来排除所有的特殊情况。新代码引入了一个新的实例变量extraAppendBytes,以简化关键路径。这个变量跟踪缓冲区中的最后一个块之后有多少未使用的空间可用。如果没有可用的空间,或者缓冲区中的最后一块不是内部块,或者缓冲区根本不包含块,那么extraAppendBytes为零。图20.3中的代码表示处理这种常见情况的最少可能的代码量。

注意:只要需要,对totalLength的更新可以通过重新计算各个块的总缓冲区长度来消除。但是,对于具有许多块的大型缓冲区,这种方法将非常昂贵,并且获取总的缓冲区长度是另一种常见的操作。因此,我们选择向alloc添加少量的额外开销,以确保缓冲区长度总是立即可用。

新代码的速度大约是旧代码的两倍:使用内部存储将1字节字符串追加到缓冲区所需的总时间从8.8 ns降至4.75 ns。由于修订,许多其他缓冲操作也加快了速度。例如,构造一个新缓冲区、在内部存储中追加一个小块以及销毁缓冲区所需的时间从24纳秒减少到12纳秒。

20.5 结论

这一章中最重要的是,干净的设计和高性能是兼容的。重写的缓冲区类将其性能提高了2倍,同时简化了其设计并将代码大小减少了20%。复杂的代码往往很慢,因为它做的是无关的或冗余的工作。另一方面,如果您编写干净、简单的代码,那么您的系统可能足够快,因此您不必首先担心性能问题。在少数确实需要优化性能的情况下,关键还是简单性:找到对性能最重要的关键路径,并使它们尽可能简单。

posted @ 2019-12-31 14:40  peida  阅读(...)  评论(...编辑  收藏