类似于 CS61C 提出的体系结构经典 idea,我也想在这里总结一下我在学习/读论文过程中看到的可复用的 idea。很多文章就是对这些 idea 进行排列组合,用于实现 tradeoff 的一部分,实现整体的性能目标。
分层以利用局部性
数据的访问存在局部性。指令的局部性体现在循环和经常调用的函数上,数据的局部性体现在数组上。存在局部性的时候,就可以用少量快速、昂贵的存储(上层)和大量慢速、廉价的存储(下层)进行组合,实现既快速又大量的存储。这里纯粹是讲利用局部性达到的提升。假如上层($t_1$)比下层($t_2$)快20倍,但是直接访问上层内容命中率为95%,那么访问数据期望的时间是$0.95t_1+0.05t_2=1.95t_1$,命中率更高时效果更好。
内存层级系统是最经典的例子:寄存器,L1i/L1d Cache, L2 Cache, L3 Cache, 内存(虽然机制有所不同,但是可以加上硬盘)一层一层叠起来,实现接近寄存器读写速度和硬盘容量的存储。这中间还可以再添加层级,例如固态硬盘的控制芯片一般会自带一些缓存块和一个缓存系统。类似的例子还有用于地址翻译的 TLB 会带有页表的 Cache,DPDK 的内存池会在每个核设置 Cache,操作系统会缓存文件系统的元信息/文件内容,存储敏感的应用程序(如数据库)会自行建立对硬盘数据的缓存等等。无论在硬件还是软件中,Cache 的思想都是十分常用的。
Fast Path
这是一种优化复杂逻辑的方法。软件中经常出现这种情况:某部分的逻辑很复杂,但是如果加以一定的假设/理想条件,逻辑就能得到大幅的简化。如果这种假设经常能够被满足,那么就可以为满足这种假设的情况单独编写一个简单的分支,这就是 Fast Path,而只在假设不满足的时候进入复杂分支。
存在这种优化的信号通常是:为了解决某个不经常出现的问题,引入了某种新的数据结构/逻辑,而新逻辑的 overhead 非常高。
例如,TCP 为了解决不经常出现的乱序包问题而引入了重排 buffer 这种数据结构,于是每收到一个包都需要在重排 buffer 中寻找它的位置,就多了一份访问链表/红黑树的 overhead。如果不认真加以解决,这份开销会在每次收到数据包时发生,影响严重。解决的方案就是,对经常出现的情况(数据包顺序到来)进行特殊的检查,这里就是存储一个待接收的序列号,发现序列号等于待接收的号时就直接放在重排buffer的开头。另一个例子来自 SRNIC(NSDI 23),RDMA 为了解决不经常出现的乱序问题而在硬件中引入了 bitmap 结构、重传队列结构和复杂的重传逻辑带来较高内存开销,解决方案就是只在硬件实现顺序到来的部分逻辑,监测到乱序时就通知软件进行处理,以节约硬件资源。由于数据中心网络中乱序很少发生,对整体性能的影响很小。
批处理
批处理通常用于将一些较高的、重复的 overhead 均摊到多次操作中。例如 mTCP 与 I/O 库的交互就是批处理式的,网卡一次传递给 mTCP 多个数据包,mTCP 再生成一批事件传递给应用,均摊了 mTCP 与 I/O 库交互的上下文切换等开销(I/O 库与网卡的交互也是批处理式的,均摊了一些昂贵的 PCIe 操作开销)。再例如 TCP delayed ACK 是希望 TCP 实现不要收到每个数据包都发送 ACK(平均每 2 个包发送一个 ACK 是推荐的量,这个量的选取还考虑到了 ACK 承载的拥塞控制功能,如果没有这方面考虑可以将发送频率设置得更低),TCP Nagle 是希望 TCP 不要发送过多的小数据包,而是攒成更大的数据包进行发送。显存对不同区域内容的写入顺序要求并不严格,因此可以将需要写入显存的数据暂存起来,再一起(成批)写入显存的硬件,以均摊与显存交互的开销。这些都可以看做批处理思想的例子。
批处理涉及到平均处理时间和响应时间的 tradeoff,批处理时平均处理时间比较短,但是最早到来的请求需要等待其他请求的到来才能被写入,所以响应时间会变慢。如果能取得平衡就能得到较好的效果。
抽象、生成与中间层
编译器的诞生可以说是计算机发展史上的一次工业革命,它将人们从手写汇编的繁重劳动中解放出来,开始用高级语言编程。这里要说的概念都是从编译器中得到的启示。
抽象
编译器的工作就是根据抽象而人类易懂的高级语言,生成功能简单、易于执行的低级语言。抽象从来没有增加程序员能做的事情:它只是对程序员进行了更多的限制。但这种限制降低了程序员的心智模型,一旦理解高级语言后,高级语言编写的程序就(比低级语言)更加易懂。高级语言可以针对其要表达的内容简化语法,从编译器和程序员共同认同的假定中推断信息。编程语言的例子不必再举,举一些其他抽象的例子:
- 操作系统将对硬件的操作抽象为系统调用,这里的低级语言相当于操作硬件的寄存器,高级语言就是系统调用。例如将对硬盘/网络的访问抽象为文件,只允许程序员把它当成流来读取/写入(从编译器和程序员共同认同的假定中推断出:按流操作的能力已经足够),简化了程序员对硬盘/网卡的操作,十分易懂,同时还实现了对硬件统一、高效、安全的管理。
- Rubik 是一个用于生成网络中间件的领域特定语言,将连接抽象为 id, sequence, 状态机和数据的集合,只允许程序员操作这些数据结构,降低了心智开销。这样的数据结构设计,以及优化过程中用到的假设都从编译器和程序员共同认同的假定中推断出非常多的信息。
- 极端一点的例子:导航系统的(起点,终点)语言就是一种抽象语言,补充语法有选择交通工具、红灯少/价格便宜/时间短的偏好选择等。
生成
从高级语言生成低级语言的过程是自动化的,特别地,对代码的优化过程也可以自动化进行。这说明编写低级语言的过程中隐含了许多重复性的劳动,它们被自动化后减少了程序员的心智负担,而且机器通常做得比程序员还要好。
中间层
在源语言和目标语言中间可以插入中间语言,将源语言先编译成中间语言,再编译成目标语言。通常,这里的中间语言是对各种目标语言共性的提炼,因此语言前端只需编译到同一种中间语言,中间语言再编译成各种目标语言,前端的工作便无需针对多种目标语言重复进行,减少了重复的工作量。同时,中间语言比源语言更加具体、更加接近机器执行的语义,因此便于进行优化(这些几乎是照着 LLVM 说的)。此外,Rubik 也用到了中间语言进行优化(目标语言是 C 语言,目前看来没有多目标语言的必要);DPDK 的 EAL、SQL Server 的 SQLPAL 用中间层的方式实现了跨操作系统,Nginx 自己实现的事件处理层实现了多种系统调用(epoll/kqueue/IOCP)之上的事件处理。