性能优化是软件开发中一个不可或缺的环节,也有很多人写文章来讲述他们是如何优化某个 XXX 场景的性能。具体案例固然重要,但这篇文章我更希望从一个更抽象的维度来谈谈这个话题,也就是性能优化的基本原则。这些原则会作为我自己做性能优化的指导,在没有现成方案时提供一些思路。
这里我把性能优化的手段分为了三大类:
- 减少工作量
- 更高效地利用资源
- 工程优化
接下来我们逐个展开,介绍每个类型的核心思路、常见做法和具体案例。
减少工作量
顾名思义,就是减少计算机 CPU / GPU 要执行的操作。宏观上来说,完成单位工作(一件事情)所需的总指令数、访存次数越少,其效率往往就越高。
使用更好的算法或数据结构
这是减少工作量最典型的做法,我们日常遇到的大多数性能问题其实都可以通过换一个算法来解决,举个实际的例子:VSCode 只是将 Gap Buffer 换成了 Piece Table,超大文本编辑性能就获得了质的提升。我们这里暂且不讨论 Piece Table 的原理以及它的实现有多复杂,这个优化的核心思想就是“换一个数据结构”,而其他的工作则都是为它服务的。找到软件中的热点代码,然后考虑是否有更好的算法可以达到相同的结果。当然,没有任何算法和数据结构是完美的,通常我们都需要根据实际场景做取舍。但想找到一个全面超越当前实现的算法可能并不难,第一步不妨先实现这个目标。
换一种思路或方向
有的时候单纯优化算法可能短时间内也很难达到更好的性能了,那这个时候就要退一步思考,这些操作是不是可以直接省略掉。在前端打包工具领域,Vite 就采用了 bundless(由 Snowpack 提出)的模式,将打包这个步骤直接省略了。这也意味着,其他工具再怎么优化打包部分的性能,也可能永远赶不上 Vite 的“不打包”。当然,Vite 的做法其实也是一种开销转移,把加载模块的工作转移给了浏览器,这种做法在后文也会提到。这个方案的施行往往需要开发者站在比较全局的视角去审视当前的软件架构,它可能比单点优化更难,而且也不一定就存在更好的架构。
接受“更差”的结果
当某个东西实在无法进一步优化时,这是一个最终手段。某些情况下,我们并不需要那么精细的结果,尤其在图形渲染领域。大部分游戏都提供不同的画质选项,或者提供类似 DLSS 的技术供玩家选择,这背后就是性能和结果的取舍。另外一个例子就是大部分全文搜索引擎都采用分词索引的方式,也就是说用户没有办法像在文本编辑器里一样搜索任意字符串。因为精准搜索字符串的性能开销太大,而一般用户只会按关键词搜索信息,因此也没有必要花费性能去实现这个功能,尽管支持它会带来更好的用户体验。
事实上很多软件和算法的设计都离不开这个思路,在允许提供更不精准结果的前提下去构想整个设计,就比如各种有损压缩算法、Bloom Filter 和各种 sound 但不 complete 的编译器。
更高效地利用资源
上面我们都在谈如何让完成一件事情变得更简单、更有效率,但有的时候无论再怎么优化,单位工作就是那么复杂。这个时候就要换个思路,如何更高效地利用现有的资源。
缓存
首先最简单的就是用空间换时间 —— 缓存。有很多算法会 memoize 一些中间结果以便提升效率,动态规划问题的核心就是它。但这里说的不是算法里的缓存,而是系统宏观层面的缓存。例如 app 在下载图片资源时会写入磁盘缓存,以便在下次启动时可以更快地展示。编译器会生成预编译缓存,以便在后续编译项目时跳过重新 parse 的环节。React 也提供了 useMemo
和 memo
组件,在重新渲染时可以复用一部分计算结果。
随着现在内存和存储成本越来越低,大家也更倾向于选择用空间换时间。不过缓存并不适合做首次使用性能的优化,因为它并没有减少单位工作的时间,只能为后续连续使用带来一些性能提升。
并行化
为了减少单位工作耗时,我们可以尝试将工作并行化。如果单位工作里有相互独立的步骤,并且我们有更多计算资源(无论是多核还是多机),那么就可以将这些独立的步骤派发出去并行执行。有些分治思想的算法亦适合采用这种方式来进一步优化。
这里需要注意的是并行和并发的区别,理想的并行是一定能够减少运行时间的,因为工作量被多个计算单元分摊了。而并发一般是指宏观层面的,就是看上去在同时做多件事情,但整体时间并没有缩短,因为计算单元只有一个。由于一些错误的同步机制用法,并行很容易退化成并发,例如两个线程频繁抢占同一个锁,或者锁的粒度过大。在这种情况下,虽然我们有两个计算单元,但它们会经常互相等待,微观层面并没有在并行计算。
另一个值得关注的就是并发数,如果并发数太少,我们就不能充分利用计算资源,而并发数太多又会导致过度抢占,从而也会降低效率。如何选择并发数取决于两个因素:工作性质和可用计算资源。IO 密集型工作可以适当提升并发数,因为它们通常只是延迟高,实际消耗的 CPU 周期很少。计算密集型工作就需要根据自己的可用计算资源来决定了,如本地执行的程序通常使用与 CPU 核数相同的并发数,理想情况下所有核心都可以同时工作。当然,有些 CPU 可能会支持超线程等技术,我们可以适当增加每个核的并发数。
时间分片
上面我们说的是并行,但有些场景我们无法利用并行,那么不妨试试并发。并发也可以叫做时间分片,即把所有工作都分成很多细小的碎片。在单位时间内只执行某个工作的一个碎片,这样就可以快速在不同工作间切换,同时推进所有工作的进度。通常来说,当底层环境不支持并行时,我们都可以在上层实现自己的并发。举个例子,在 SMP 出现之前,操作系统仍然可以实现多任务和多线程,包括协作式和抢占式。当操作系统或运行环境(例如 JavaScript runtime)不支持多线程时,我们仍然可以在代码层面实现协程,从而达到一定程度的并发。
在 GUI 领域,时间分片的思想被越来越多地使用到了。React 的 Concurrent Mode 就是一种时间分片,它通过将渲染工作分摊到多个事件循环周期内,提升应用的 responsiveness。由于每个 unit of work 耗时都很短,浏览器可以在每个工作的间隙快速响应某个突发的事件。即便某个分片可能由于切分不均匀导致耗时偏多,这也比作为一个整体更可被随时打断。客户端领域也有类似的做法,如 SwiftUI 的列表就会在空闲时间 prefetch 即将显示的内容,从而避免在显示时批量计算导致突发的高耗时。
所以如果你的应用有很高的 responsiveness 要求,或者有很多任务要处理但不追求缩短整体时间,那么可以考虑这种并发方案。
工程优化
除了前面说到的这些高维度的优化方向,我们日常开发中也可以多关注一些工程方面的优化点。这部分的具体方向就有很多了,我这里先举几个基本的例子。
内存管理优化
由于代码中的很多操作都会隐含内存管理,选择合适的内存管理方式可以帮我们节省很多微观层面的性能。我们知道,原生语言的内存分配主要分为栈上分配和堆上分配,其中栈上分配非常廉价,因为只涉及寄存器的操作;而堆上分配的性能则取决于背后的 allocator 实现如何,差异可能比较大。对于堆上分配的内存,我们可能还需要考虑释放时的额外开销。
许多操作系统都提供 libc,其中的 malloc
就是 general purpose 的内存分配器,我们大部分场景的堆内存分配都会用到它。malloc
支持分配动态大小的内存空间,并且支持灵活的生命周期,你可以随时释放之前申请的内存。然而这种泛用式的内存管理方式就意味着在某些特殊场景下,它的性能表现不一定是最好的。
举一个最简单的例子,像编译器这种生命周期很短的程序,频繁的内存管理操作其实并无太大意义,我们不值得为这临时的一点内存占用去浪费多余性能。因此,很多程序会选择使用 Arena (Region-based) Allocator 来管理内存,它的基本思路就是牺牲灵活释放来换取更好的性能。这些分配器通常会从操作系统那里获取几页很大的内存,然后通过一个指针维护当前已经实际分配掉的空间,因此分配操作的性能几乎等同于栈上分配。而程序并不能随意释放之前分配的内存,必须要在所有已分配内存使用完毕后整体释放,释放方式也是将指针置为初始状态。编译器可以按主要步骤划分内存管理边界,如分为 parse、optimize 和 codegen 阶段,每个阶段完全结束后整体释放一次内存。
另外一种内存分配器是 Slab Allocator,又叫固定大小内存分配器。顾名思义,它是通过牺牲分配大小的灵活性来换取更好的性能。由于每次分配的内存大小固定,因此我们可以用更简单的数据结构来维护这些内存块,也无需担心碎片的问题。这种内存分配器常见于操作系统内核,因为内核的大部分数据结构都是固定大小,且列表常用链表实现,因此没有太多分配动态大小内存的场景。Linux 源码中也会把一个 slab 称为 cache,有对象复用池的意思。Slab 在平时开发时也有很多实用场景,基本上所有固定大小的对象都可以使用 slab 来分配内存,并且很多 malloc 在内部实现中也会通过 size classes 来优化常见小尺寸内存的分配。
减少抽象开销
现代软件越来越倾向于使用更 high-level、设计更复杂的框架。在前端领域,几年前大家还在讨论 "You don't need jQuery",而现在 Next.js 可能是很多项目的起手式。框架设计出来的初衷是当大家 DRY 以及规范代码风格的,但越来越复杂的框架也在带来越来越大的无用开销。
在以前,由于硬件性能的限制,程序员们会绞尽脑汁通过各种方案减少不必要的开销。回过头看那时候的框架,它们往往比较 low-level,专注于把高效的操作封装成可以复用的组件。大家使用这些框架 / 类库只是为了避免重复实现这些复杂且与业务无关的操作。而现在的框架更注重开发模式的定义,以及表现力、灵活性等方面,性能往往不是第一考量。对比 AppKit 和 SwiftUI 这两个不同时代的产物,区别会非常明显。
人们虽然追求所谓的零开销抽象,但遗憾的是,抽象和性能往往是不能并存的。React 为了函数式这个抽象做了太多的优化,但这种抽象方式无论如何都不可能比直接的 DOM 操作高效。使用它的人,必须要为不必要性能开销买单。你可能会说这是一种 trade off,用一些性能损耗来换取更好的开发体验,但很多时候这并不是一种 trade off,而是一种思维惯性。
当然,这里的抽象不仅局限于框架,编程语言中的抽象也可能带来隐藏的性能开销。很多语言都会在一些不起眼的操作中执行隐藏额外的控制流,其中甚至会存在内存分配,例如 C++ 的 ->
可能由于运算符重载而隐藏额外的函数调用。因此现在也有一些语言以无隐藏控制流作为亮点,就像 Zig 的文档所描述的,如果一段代码看起来没有函数调用,那么它就没有函数调用。
使用更高效的接口
当我们必须依赖某个上层的 vendor / platform 时,请尽可能选择一个更高效的接口。从工程角度讲,选择可能比努力更重要。举个例子,在 macOS 中遍历文件系统中的一个目录,最普遍的做法就是 opendir
+ readdir
。但由于接口设计的原因,用户必须多次调用 readdir
来读取目录中的所有项,无论应用层如何优化,调用次数都是不可能进一步减少的。但如果你知道 getattrlistbulk
这个系统调用,你就会发现不做任何优化,遍历目录的性能就免费提升好几倍,因为这个系统调用可以一次性返回整个目录的内容。
当我们遇到一个性能瓶颈,第一时间可能都是想去优化自己的代码,认为是自己的代码不够完美。但我们也可以花时间去调研一下我们的上游,看看它是不是也提供了一些更高效的接口可以利用。虽然有时候我们使用的接口已经是最高效的了,但是有这个意识也是很重要的。
基于 Profile 的微观优化
很多时候,我们的算法看上去已经是最优的了,但实际的性能可能还存在优化空间。这个时候我们可以使用一些 profile 工具来从微观层面检查现存的性能瓶颈,例如 CPU 的 cache miss、brunch misprediction 等。要知道,主内存访问相比 L1 缓存访问耗时差了上百倍,而这些微观的差异在算法层面可能并不会体现。Xcode 26 的 Instruments 提供了 Processor Trace,可以很直观地展示出各种 CPU 层面的性能瓶颈。

对于一些性能敏感的场景,我们可以基于这些数据,有针对性的对算法的实现进行代码层面的调优。当然,具体的方法有很多,这里就拿统计字符串中空格个数来举个例子:
fn count_spaces(s: &str) -> usize {
let mut num_spaces = 0;
for ch in s.bytes() {
if matches!(ch, b'\t' | b'\n' | b'\x0C' | b'\r' | b' ') {
num_spaces += 1;
}
}
num_spaces
}
上面这段代码在每次循环中都会判断 ch
是否为几种空白字符,这会产生很多 branching 指令。而当输入的字符串没有规律的时候,CPU 的分支预测会有很大概率不被命中,从而白白浪费很多性能。但如果我们预先将 num_spaces
每次增加的值根据不同字符打到一张表里,通过查表将代码改为 branchless 的,那么性能将会大大提升:
fn count_spaces_branchless(s: &str) -> usize {
let mut num_spaces = 0;
for ch in s.bytes() {
num_spaces += LOOKUP_TABLE[ch as usize] as usize;
}
num_spaces
}
这里简单做了一个 benchmark,对比结果如下:
running 2 tests
test bench_count_spaces ... bench: 12,131.20 ns/iter (+/- 757.45)
test bench_count_spaces_branchless ... bench: 5,928.38 ns/iter (+/- 377.73)
结语
性能优化的具体方式不计其数,但归根结底,无论是什么领域,最终都是围绕上面这些大方向展开。当面对一些新的问题的时候,不妨把这些方向作为一个 checklist,逐一确认它们是否都已经做到最好了。相信通过这些方式,把当前性能优化一点点,并不是一件非常难的事情。