多年软件开发经验:我在软件性能优化上的4大领悟

发表时间: 2020-08-18 15:23

英文原文: https://blog.nelhage.com/post/reflections-on-performance/

在职业生涯中,我至少参加了三个对软件性能表现有一定要求的项目,它们分别是 Livegrep 、 Taktician 和 Sorbet 。此外,我还对正使用的工具做了许多提升性能的工作。

一、性能是软件的一个重要特性

我很赞同这样一个观点,即软件性能不是独立于软件功能或软件特性集合的一个属性。性能(尤其是指能显著提升速度的性能)本身就是软件的一项功能,它从根本上改变了一个软件工具的使用和感知方式。

在推出 Sorbet 后,我们从 Stripe 工程师那里得到大量反馈和赞赏,因为这个软件工具的性能非常优越。

与之前缓慢的工具软件使用体验相比,开发人员真正体会到用高性能软件带来的快感(例如,对 Stripe 的代码库进行类型检查,Sorbet 从冷启动加载都会比 Ruby 正常加载要快,更不用说执行其他代码,在我看来,这是 Ruby 生态不太好的地方)。

我认为性能的价值在普遍意义上非常容易理解——许多工程师都知道并经常讨论响应时间感知阈值或 延迟对转换率的影响——但能真正理解性能内在含义的人实际上很少,大部分只是纸上谈兵。最近感觉抱怨软件运行速度缓慢的人很多,但是也很少有团队可以为此做些什么,以至于工具的性能变得越来越慢。

从业经验告诉我,尽管我们的工具让编写高性能软件变得越来越难,但产出高性能软件不是没有可能,而且这一努力是值得的。

二、性能改变了用户使用软件的方式

毋庸置疑,用户更偏爱性能好、速度快的应用软件,因为与速度慢的软件相比,这会带来更好的用户体验

高性能软件改变了用户使用软件的方式 ,这一点也许体现的不是很明显。用户通常会使用多种策略来实现目标任务,并且他们将会越来越频繁地选择使用更快的工具。性能更好的工具不仅可以帮助用户更快地完成任务,而且还能让用户以全新方式完成各种类型的任务。

在做Sorbet 和Livegrep 工作时,我清楚地认识到这一点:

Sorbet 项目的主要目标之一是在开发过程中,为用户输入的代码提供快速反馈。Stripe 始终维护着一个扩展测试套件,该测试套件有较高的质量以及代码覆盖率,并且其运行时间始终维持在 10-15 分钟内。我们希望 Sorbet 可以减少一些软件测试和运行时错误,但是缩小测试用例或在生产环境中获得额外的安全性并不是我们的主要目标。相反,我们希望获得的最大收益是缩小开发过程的反馈循环,并使用户对其代码的操作反馈比以前更快。

在开发环境中,Stripe 的许多测试会执行 10-20 秒甚至更多。因为我们成功构建了 Sorbet,所以可以在那个时间窗口内对整个代码库进行类型检查,这让开发人员能针对自己的代码获得合理的反馈,快速的检查基本输入问题、API 的不合理使用问题以及其他低级错误。这是他们为快速开发所做的选择,我们经常看到用户在产品开发和上线早期就选择 Sorbet 作为他们代码检查的工具。及时获得代码检查的反馈,这可以增加开发人员的信心,从而节省大量时间,这一点非常重要。

快也意味着用户对误判有一定的容忍度(Sorbet 识别出的错误代码可能实际情况并不会出错),前提是软件知道如何解决错误(比如增加 T.must 声明)。如果 Sorbet 与 CI 的运行时间运行相近,比如 10 分钟左右,那么用户与 Sorbet 的关系将会是另一番样子。它必须在 CI 的基础上提供非常明显且有差异的附加价值,而且尽量不会为了兼顾 Sorbet 而要求用户进行更改,因为这个时候它不再具有向用户提供早期反馈的优势。

观察用户使用 livegrep 的情况,我发现性能提升的另一个好处。Livegrep 的目标是在 100 毫秒内响应大多数搜索, 100 毫秒这个阈值非空穴来风,它有据可查的,在此阈值内,大多数用户会感觉到响应几乎是瞬时的,没有什么滞后之感。由于 livegrep 是如此之快,用户能以一种交互的方式来使用它,这种交互方式在其他搜索引擎上几乎很少见到:用户输入一个初始查询,紧接着,如果得到大量或少量的搜索结果,用户可以根据结果列表进行再编辑,然后获得一组新的结果,这会进一步完善或扩展搜索条件,直到找到用户真正要查询的结果。

我创建 livegrep 来用在正则表达式上,这在很大程度上是一个有趣的技术挑战——理论上,对于 Livegrep 的许多使用场景来说,语法或语义感知搜索听起来更好一些。但是,我从未见过像 livegrep 一样快的语法感知工具,而且我逐渐体会到 livegrep 的交互功能引出了它额外的功能,因为它使迭代和优化搜索变得容易。而且,大多数工程师已经对正则表达式有所了解,并且能以交互方式进行实验,实时浏览和查看结果,相对于使用复杂的查询语法来实现搜索,livegrep 显得更加用户友好,方便使用。

三、性能需要贯穿项目整个生命周期

在项目的整个生命周期中,追求性能需要付出一定的努力。如今, 不用担心性能问题这个观点越来越流行,尤其是在项目初期。我们经常听到这些论调:

  • “过早的优化是万恶之源”
  • “先运行,再纠正,后调优”
  • “CPU 的时间总是比工程师的时间廉价”

我们会从那些认为 Ruby 或 Python“ 足够快”的人那里听到类似的观点。这个观点要表达的似乎是性能仅仅是最后才要考虑的事情,有效的代码才最关键。

我了解到的普遍流行的哲学观点似乎是,首先应该以最快的方式编写出应用程序,只有在程序正常工作后,再转向利用分析器并开始逐个优化热点代码,甚至最后可能将某个组件使用更快的编程语言或技术进行重构。

这确实是一个不错的并被普遍默认接受的建议,但也了解到,认识到这些观点的局限性,并在项目开始时能够寻求其他的实现形式确实很重要。特别是,我开始相信“功能优先,性能让步”模型很少会产出真正高性能的软件(并且如上所述,我认为真正快速的软件是一个值得追求的目标)。我总结了上面的方法论无效的两个主要原因:

1. 软件架构会直接影响性能

系统的基本架构(高级结构、数据流和组织形式)通常会对性能产生长远影响。正如之前讨论的那样,我们在Sorbet 中所采取的一个设计决策是仅在方法内执行局部类型推断。这一决定对类型系统和用户体验产生了影响,但同时也使Sorbet 大大简化、更快并且更易于并行化和增量扩展。

提前进行架构设计,后续的维护成本会降低,否则,后期如有修改,成本陡增。Flow 团队仍处在持续多年的重构计划当中,他们的目标是将Flow 和Facebook 的代码迁移到本地化模型中(我想明确一点,我并不是在批评Flow 团队的选择。他们的工具在Facebook 内部和外部都非常成功,而且他们做出的决定在当时来看是没有问题的。他们在博客中讨论过这个问题,我很欣赏他们能对外公开透明。不过,我发现这个案例在一定程度上可以说明早期架构决策会对现在产生一定的影响)。

如果你要构建真正的高性能软件,那么在制定早期设计和体系架构决策时需要牢记性能因素影响,以免日后陷入非常尴尬的境地。

2. 优化性能不能只关注热点代码

像大多数编译器和类型检查器一样,Sorbet 也没有什么热点代码。CPU 时间在软件代码的不同功能部分被相对均匀的分配和执行。这种均匀分散执行从根本上意味着,不可能对一个检查器通过优化热点代码的形式来提升性能,因为几乎没有热点代码可以进一步优化。

相反,我在上一篇文章中讨论的许多技术,例如缓存优化的数据结构或用 C ++ 重构实现关键模块,实质上会使类型检查器中的每一行代码都执行得更快,从而使整个应用的性能得到提升。

我最喜欢的性能提升案例之一,就是 SQLite 3.8.7 版本比以前的版本性能提升 50%,所有这些都是通过大量累加的微小性能改进而实现的,其中每个优化获得的性能增加不到 1%。这个例子提到,每个优化对整体代码的优化提升微不足道,即使是这样,性能优化随着微小性能改进的累积可以逐步放大。尽管,最后 SQLite 开发人员是在产品成熟之际进行了这个优化工作,但是,如果能在产品早期进行 1% 的优化,后续的优化工作也会简单很多。

四、从性能的基础出发能简化架构

我发现的另一个观点是,相对于从指定的功能级别开始优化,从内核开始高性能设计可以最终极大地简化软件项目的体系架构

在我们编写 Sorbet 之前,Stripe 进行了一些内部 Ruby 代码的静态分析,该分析是 Sorbet 整体分析(全局常量解析)有限的一小部分。这些代码分析由 Ruby 实现,构建在 Parser gem 之上,性能比 Sorbet 明显慢得多,并且由于 Ruby 的 GVL 限制,使得并行化非常有很大的挑战性。为解决并行化问题,我们最终使用基于fork的并行化构建了一个日趋复杂的缓存解决方案,只是为了保持运行时间在可接受的范围内。由于大大提高基准性能以及简化了系统内部并行性,Sorbet 能够完胜那写没有使用缓存策略的系统。

有一个普遍的现象是:尝试为速度较慢的系统提升性能通常会增加系统复杂性,例如复杂的缓存系统、分布式系统或为进行详细的增量计算而设计的明细记录系统。这些功能都会增加复杂性,可能引入新类型的 bug,还会增加额外的开销成本,系统性能会下降甚至更糟,从而使问题进一步加剧。

如果从一开始软件性能就够快,可能根本不需要这些附加中间层就可获得可接受的总体性能,而且相同的性能水平下,软件系统架构也会简单很多。

以 Sorbet 为例,虽然我们确实使用了一些缓存策略,而且 Sorbet 的 LSP 服务在执行增量重新类型检查时有些复杂,但是与我所熟悉的使用同类类型检查器的系统相比,我们的系统还是要简单很多。我们能获得这种简单性在很大程度上是因为 Soebet 的基本性能本身就不错;Sorbet 不必费时费力地保存工作中间结果,因为它通常可以很快地就能完成工作。

Sorbet 一个非常好的特性是它的缓存格式不存在向前或向后兼容的问题:Sorbet 的发行版本可以获取自己版本 git commit 的 sha1 值,并且会忽略其他不同发行版本生成的缓存文件。这一决定意味着缓存格式可以非常简单和轻量级,并且我们不必担心数据迁移或者兼容性问题。当然,缺点就是每次我们在发布一个版本时,因为启用时暂未加载使用缓存数据,用户会短暂地感觉到软件性能有所降低,这一现象会在新的缓存加载成功后消失。由于缓存仅仅保存几秒钟,并且软件冷启动时间仍小于 20 秒,因此我们认为这是一个可以接受的折中方案,因为它带来了简单性的同时也降低了 bug 发生概率。

相比之下,我有几个朋友在 Dropbox 公司从事 MyPy 的部署工作,它们在未加载缓存启动运行系统时,可能需要十分钟甚至更长的时间。这种巨大的性能差异意味着他们使用着完全不同的算法设计,并且不得不去更加担心何时以及当前是否可以使缓存失效,这就增加了性能维护的复杂性,而这些我们都能完全避免。

随着 Stripe 的 monorepo 的持续增长,仅仅依赖 Sorbet 的原始性能是远远不够的,我希望 Sorbet 最终会支持更加复杂的缓存以及增量执行;原始性能强并不是万能的。但是,一定要在给定规模水平下讨论这个问题,具体问题具体分析,这一点很重要。通过基本简单地设计就可以达到像 Sorbet 这样的性能水平,所以我们不要低估这种基本性能设计所带来的收益。

总结

我相信,我们在设计和构建软件时常常会低估性能带来的影响。在软件设计时,我们已经习惯于遵从工具或者标准库的固定选择,而忽略一些因素对软件性能的影响,也不会考虑这么做是否值得。

到 2020 年,Sorbet 软件仍然可以运行的很流畅,响应也很快,因此我们之前付出的努力都是值得的。你印象中有哪些软件,因为其优越的性能让你感到震惊,并大大提高了你的生产力?你能说出几个吗?

关注我并转发此篇文章,私信我“领取资料”,即可免费获得InfoQ价值4999元迷你书!