从用户态到内核态,应用程序、驱动程序等等,几乎任何软件中都可以找到性能病态。在Sun公司,我们花费了过去几年时间,将最先进的工具应用于Unix内核、系统库和用户应用程序,并发现许多表面上不同的性能问题实际上有着相同的根本原因。由于软件模式被认为是积极经验的抽象,我们可以将导致这些性能问题的各种方法称为反模式——应该避免而不是效仿的东西。
其中一些反模式根植于硬件问题,一些是糟糕的开发或管理实践的结果,还有一些只是常见的错误——但我们已经反复看到所有这些。在本文中,我们讨论这些错误:是什么导致了它们,如何找到它们,以及如何最好地避免它们。
软件开发通常是一个资源受限的问题。项目或产品团队很少拥有他们可能想要的所有工时。不幸的是,一个经常被忽视的领域是测量和评估性能。项目团队经常赶在进度结束前开发新功能和修复错误,而性能工作则被留作事后诸葛亮。他们常常未能制定性能目标或基准,开发人员第一次考虑性能通常是在收到来自其 Beta 测试站点的性能问题报告之后。在项目的这个阶段,我们经常开玩笑说要拿出“性能喷雾”,希望我们可以像喷上一些花哨的油漆一样快速地喷上一些性能。
这种反模式似乎显而易见,但许多项目都以惨痛的代价重新发现了这一点。如果你的团队不费心去建模或测量软件性能,或者等到项目接近尾声才开始,那么除了侥幸之外,不太可能获得好的结果。
选择基准并比较结果,最初看起来至少是一个简单的问题,但在这个过程中会犯许多错误。例如,对于Solaris的某些版本,性能指标是比以前的版本在大型系统基准测试中慢不超过2%。这是一个错误,类似于仅仅为了每年增加两磅而进行足够的锻炼。这实际上确保了Solaris的性能会随着时间的推移而下降。
这个目标也忽略了竞争现实,因为没有尝试将性能与其他操作系统进行比较。此外,大型基准测试的使用意味着,当性能出现倒退时,测试平台的匮乏确保了分析性能倒退将阻碍额外的测试。
我们应该测量什么以及如何测量?一个好的基准是
并非所有选择的基准都将满足所有这些标准,但重要的是其中一些要满足。确保选择足够的基准,以便在你的产品发布时,产品性能范围的重要部分不会成为意外——并避免选择那些不能真正代表你的客户的基准,因为你的团队最终会为错误的行为进行优化。抵制为基准优化性能的诱惑;最近发现一些操作系统通过将 getpid(2) 系统调用移到用户空间来“改进系统调用性能”就是一个完美的例子;没有真正的应用程序调用 getpid(2) 的频率足以重要到需要优化。
选择基准就是要求优化性能的那个方面,可能会以牺牲未被测量的其他方面为代价。如果作为操作系统开发人员,你想要更快的系统调用,请设计一个基准,该基准是你客户的应用程序最常调用的调用的加权平均值。小心你所要求的,因为你很可能会得到它。
对于许多软件开发人员来说,算法是他们在大学时代学到的东西,并且值得庆幸的是,与他们的日常工作没有太多关系。在Solaris 10的开发过程中,Solaris工程师修复了内核和用户库中的一大堆性能问题。在版本发布即将结束时,我们花了一些时间回顾一下已经改进了什么,改进了多少——以及性能问题的根本原因是什么。有趣的是,所有真正大的改进(比如说,超过200%)都是由算法的改变带来的。一次又一次,所有其他的性能修复——使用专门的SIMD处理器指令(如SSE2或VIS)、插入内存预取指令、周期削减——与简单地回顾和重新思考锁定算法和/或数据结构相比,都显得微不足道。
算法选择的关键部分是手头有一个真实的基准或工作负载,以支持基于实际结果而不是直觉或传说的决策。这意味着进行性能和可伸缩性工作的最有效时间是在项目的早期阶段,这可能与通常发生的情况完全相反。当处理大型n值的O(n2)算法时,所有聪明的编译选项都毫无用处。糟糕的算法是软件系统性能不佳的首要原因(可能也是第二和第三个原因)。
谁会如此轻率地编写O(n2)算法?好吧,对于小的n值,差异可能无关紧要,并且简单的算法可能确实更快。但是1992年一个完全合理的n值在2005年可能看起来相当荒谬,对于像操作系统这样长寿命的产品或源代码库来说,这是一个真正的问题。例如,Solaris VM系统的基本设计来自SunOS 4.0,它是在1985年开发的。那时,一台配置良好的机器可能只有大约1000页的真实内存。二十年后,一台配置良好的桌面机器可能拥有数百万页,而大型服务器则更多。
是的,我们已经进行了广泛的更改来弥补这一点,但是如果我们从头开始重写VM系统,我们将根据今天的条件做出截然不同的选择。相同问题的其他例子包括索引不再与流行的查询对齐的数据库、对于当前问题来说尺寸过小的开放哈希表、哈希函数不再适用于输入更改等等。
捕获这类问题最有价值的方法之一是,原始开发人员既要记录关于影响代码的外部性的假设,又要提供某种程序化的方法来稍后验证这些假设。例如,一种有用的技术是跟踪开放哈希上的最大哈希链长度以及哈希表总人口;这使得可以轻松识别尺寸不足(或需要自调整大小)的哈希表和糟糕的哈希函数。另一种技术是在违反假设时强制错误;这会导致硬故障,并希望有一个清晰的错误,而不是原因不明的神秘减速;当然,这可能不适用于某些应用程序。软件重用是一个很好的目标,但要注意不要违反在其开发过程中所做的假设。
在20世纪90年代早期,我们努力调整OpenWindows的性能时,我们发现工程用户经常抱怨他们在X桌面上的滚动性能。在检查了shell、终端模拟器和X服务器的性能之后,我们发现仅仅为了将一行文本滚动到屏幕上,就涉及了惊人的工作量。这项工作很好地分布在各种不同的地方,显然没有一个地方可以进行我们性能工程师当然在项目尾声所寻找的快速修复。我们的讨论使我们产生了尽可能避免滚动屏幕的想法,因此我们开始寻找在仍然保留现有语义和用户体验的同时避免这样做的方法。经过一番思考,我们引入了一个缓冲流模块,该模块将被连接到伪终端。这将把来自用户应用程序的多个小写入合并成终端模拟器可以一次读取的单个大块。
终端模拟器被重新编码,以从应用程序中获取整块文本并将其放置在窗口滚动缓冲区中,然后跳转到缓冲区的末尾。这使得滚动性能提高了十倍以上——并且使系统感觉更快,即使在负载下也是如此,因为我们做的工作少了很多。这个轶事的重点是显而易见的:如果你的应用程序正在做不必要或不受赏识的工作——多次重绘屏幕、过于频繁地计算统计信息等等——那么消除这种浪费是性能工作的有利可图的领域。对于应用程序来说,通常重要的是程序的最终状态,而不是用于达到该状态的确切步骤序列。通常,有一种可用的快捷方式可以让我们更快地达到目标。这就像缩短赛道而不是加速汽车:除了正确使用的内存预取指令外,软件中更快的唯一方法是减少工作量。
在性能审计期间,我们经常发现看似经过仔细调整和优化的软件,但结果却发现,手动展开的循环、寄存器声明、内联函数(有时用汇编语言编写)以及其他明显的长期调整努力的产物实际上是性能装饰。它们使软件看起来很快,但对实际交付的性能没有积极影响,这与汽车上的一对镀铬气门罩没有什么不同。这些优化通常是在初始开发期间由刚从上一个项目的崩溃调整阶段出来的工程师完成的。
过早优化实际上经常会对实际基准测试的性能产生不利影响,要么通过增加指令缓存足迹到足以导致未命中和流水线停顿,要么混淆编译器中的寄存器分配器,或者有时通过阻止其他工程师仔细寻找性能问题的实际来源。低级周期削减有其用武之地——但仅在性能工作的最后阶段,而不是在初始代码开发期间。即使在那时,也应仔细记录进行调整的条件,以帮助其他人稍后评估这些条件是否仍然有效。
如前所述,最有效的软件性能工作侧重于算法,而不是低级细节;没有什么比手工编码的汇编链表而不是用C语言编写的简单哈希表更愚蠢的了。这种低级优化不是一个好主意的另一个原因是它往往以平台为中心:对于需要在各种系统或处理器上良好运行的软件,这些技术通常需要为每个平台提供这些例程的单独版本——从开发、可移植性和测试的角度来看,这是一种痛苦且昂贵的方法。在确定(基于实际实验的结果,而不是直觉)这将是提高性能的一种经济有效的方法之前,请等待进行此类工作。Donald Knuth 说过:“过早优化是万恶之源。”
我们这些从事Solaris性能工作的人经常被要求确定应用程序性能不佳的原因,通常认为必须存在一些底层操作系统缺陷导致了问题。尽管这有时最终成为一个问题,但在绝大多数情况下,问题实际上出在应用程序本身——然后,通常出在客户使用应用程序的方式上。
思考应用程序编程的本质,原因就很清楚了。应用程序顶层代码的每一行通常都会导致软件堆栈更深处的其他地方的大量工作。因此,顶层的低效率具有很大的乘数效应,放大了它们的影响,使得堆栈顶部成为寻找可能加速的好地方。然而,传统上,由于缺乏合适的工具来观察应用程序的行为,性能工程师试图使用低级工具(如 truss、strace 或各种性能监控命令(如 iostat))来诊断性能问题;这通常导致试图加速常见的操作系统调用或 I/O 操作,而不是修改应用程序以减少正在进行的调用次数。例如,如果你面临数据库应用程序性能问题,请首先查看 SQL;一旦对此进行了调整,数据库正确索引等,那么也许是时候查看磁盘利用率了。
幸运的是,Solaris 10中诸如 DTrace 之类的高级动态instrumentation工具的出现,使得观察高级应用程序行为以及将由此产生的系统影响归因变得更加容易;这应该有助于系统性能工程师专注于性能不佳的真正原因。
许多软件开发人员喜欢使用分层来在其软件中提供各种级别的抽象。虽然分层在某种程度上很有用,但不谨慎地使用它会显着增加堆栈数据缓存足迹、TLB(转换后备缓冲区)未命中和函数调用开销。此外,数据隐藏通常会强制在函数调用中添加过多的参数,或者创建新的结构来保存参数集。一旦有多个特定层的用户,修改就会变得更加困难,并且性能权衡会随着时间的推移而累积。这个问题的一个经典例子是像 Mozilla 这样的可移植应用程序使用各种窗口系统工具包;应用程序和工具包中的各种抽象层导致了相当惊人的深调用堆栈,即使是轻微的功能练习也是如此。虽然这确实产生了可移植的应用程序,但性能影响是显着的;抽象和实现效率之间的这种紧张关系迫使我们定期重新评估我们的实现。总的来说,层是用于蛋糕的,而不是用于软件的。
一旦程序员熟悉了线程(或者更糟糕的是,多个协作进程),最常见的错误之一是决定为每个连接或其他单位的待处理工作使用一个线程(或进程)。诚然,这是一个简单的编程模型,每个连接/任务的状态方便地保存在线程堆栈上。这在小型或 LAN 测试环境中运行良好,当连接快速耗尽并且少量线程足以完全加载机器时。一旦此应用程序部署到数千个慢速连接,程序员就会发现他们的骄傲和喜悦需要数千个线程才能有效地使用硬件,而这数千个线程实际上表现不佳,因为机器现在由于所有这些堆栈而承受了大量的 TLB 和缓存压力。
正确的答案是将线程数限制为更合理的数量(接近 CPU 数量),并使用工作池模型和异步 I/O 将工作线程多路复用到要完成的任务上。这些类型的应用程序架构更容易扩展,并且在重负载下表现得更加优雅。毕竟,如果应用程序的净吞吐量在超过某个负载级别后开始下降,那么情况本质上是不稳定的——这不是一个好地方。
现代 CPU 比连接到它们的内存系统快得多。一些处理器设计使用三级缓存来隐藏内存访问的延迟,多级 TLB 现在也变得越来越普遍。这些缓存和 TLB 使用不同程度的关联性或路数来将应用程序负载分散在缓存中,但这种技术经常被其他性能优化意外地破坏。
一个简单的例子是性能工具,它重新排序共享库中的函数,将最常用的函数放在库的开头,这显然是降低 ITLB(指令转换后备缓冲区)未命中率和分页的合理策略。然而,当应用于许多共享库时,这会导致每个共享库的文本段的开头比其他部分更频繁地被访问;由于 SPARC 32 位 ABI(应用程序二进制接口)要求可执行文本段的 64KB 对齐,这意味着那些落在 64KB 边界上的 TLB 条目更常用,有效地将 ITLB 的大小减少了 8 倍。当大量相同的进程使用大页来处理它们的堆或堆栈以避免过度的 TLB 压力时,也看到了同样的效果:由于较新的 CPU 的页面大小大于它们的 L3 缓存,因此防止小页面出现热点的页面着色算法不再有效,并且缓存中的某些行变得非常过度使用。
另一个例子发生在一个数据库供应商身上,他们的代码分配了 L2 缓存大小倍数的大块共享内存(用大页分配);通过在每个大块中具有相似的访问模式,供应商显着降低了 L2 缓存的命中率。热点也可能发生在物理内存中;NUMA(非统一内存访问)机器上的分配行为倾向于一个内存区域(因此是可用内存控制器的子集)而不是另一个区域,这可能会导致平均内存访问时间显着增加。
检测和避免这种热点问题可能很困难,因为硬件计数器和工具通常缺乏直接观察这些效果的能力;一旦检测到,在没有应用程序可见的效果(如倾斜堆或堆栈地址)的情况下避免这种热点可能很困难。过去,我们成功地使用简单的程序对各种缓存和 TLB 配置进行建模,并将程序的访问模式与模型进行比较;考虑到这是在其他方面调整良好的应用程序中如此常见的性能抑制因素,因此绝对需要在这方面做更多的工作。在可能的情况下,构建相对缓存行和 TLB 条目未命中频率的简单图表(直接测量或建模)几乎可以立即指出任何有成效的进一步检查领域。通常需要将随机性故意注入到分配模式、内存布局等中,以防止热点;例如,我们已经随机化了 Solaris 内核中大型共享内存段的物理内存分配模式,以避免那些似乎总是干扰至少一个重要应用程序性能的静态模式。
在设计锁定算法时,可以考虑访问例程使用模式中的显着不对称性。频繁操作的发生频率可能比不频繁操作高几个数量级;设计利用这种不对称性的锁定算法可以产生显着的好处。一个简单的例子是使用每个桶的哈希表锁;这提高了对表的简单搜索、插入或删除的可伸缩性,同时惩罚需要访问整个表的操作,例如调整大小。算法选择的关键部分是手头有一个真实的基准,以支持基于实际结果而不是直觉或传说的决策。
一个更复杂的例子是用于锁定 Solaris 内核中 CPU 列表的方法。试图做出调度决策的不同 CPU 经常遍历此 CPU 结构列表。由于 Solaris 支持 CPU 的在线和离线,并且(在有能力的硬件上)添加新 CPU 或删除其他 CPU,因此 CPU 列表必须在(极少)需要时可修改,但同时又可以被许多 CPU 有效地遍历。当前 CPU 列表的锁定实现利用了读取访问与写入的绝大多数优势,方法是强制任何希望修改列表的 CPU 导致所有其他 CPU 运行特殊的暂停线程;因此,希望安全地遍历列表的线程只需要阻止自身的抢占。这只需要本地(非原子)内存引用。
这确保了 CPU 列表的快速、可伸缩的读取锁定,但使得修改的成本高出许多数量级且完全不可伸缩,考虑到这两个操作的相对频率,这是一个合理的权衡。
如前所述,系统设计人员使用缓存来隐藏 CPU 的内存延迟。在多处理器上,精心设计的硬件协议确保系统中只有一个缓存包含内存的修改版本;多个缓存可能包含内存的未修改副本。当一个 CPU 尝试写入当前在另一个 CPU 缓存中的内存时,会发生缓存到缓存的传输,以在 CPU 之间移动该缓存行(通常为 64 字节)的所有权。在大型多处理器上,这可能需要大量时间和可用带宽;最大限度地减少这些传输量可以显着提高可伸缩性。这些传输的原因通常很容易理解:不经常访问的代码路径中的单个计数器,由遍历该例程的每个线程(和 CPU)递增。如果该代码路径变得更频繁地使用,则交换包含该计数器的缓存行可能会成为应用程序可伸缩性的限制因素。
有些例子更难发现:由 CPU ID 索引的整数计数器数组是伪共享的经典示例,之所以如此命名,是因为 CPU 似乎没有共享计数器;问题是内存所有权的粒度为 64 字节,迫使 16 个 CPU 在同一缓存行上发生冲突。在软件中,它们不共享数据——但在硬件中它们共享数据。使用通用分配器(如 malloc(3C))为不同线程分配 8 字节内存块会产生一个更微妙的相同问题版本;多次调用 malloc 很容易返回位于同一缓存行上的不同块。
不必要的缓存行交换的另一个原因是安慰剂锁;这让程序员感觉更好,但实际上并没有做任何事情。一个经常看到的例子是由锁保护以进行读取的简单计数器。获取锁,读取计数器,然后释放锁。在运行 Solaris 的所有现代硬件上,32 位读取是原子的:锁除了不必要地降低可伸缩性外,什么也没做。
一个经常影响可伸缩性的更微妙的锁定问题是读写锁的误用。乍一看,在频繁读取器和不频繁写入器的情况下,读写锁似乎可以显着提高可伸缩性——但仔细查看这些锁定原语的实现方式,我们可以看到,我们在获取读写锁和释放读写锁时都抓取和释放一个简单的锁。因此,我们执行的原子操作是原来的两倍。如果读取锁的保持时间很短,通常是这种情况,那么我们最好只使用简单的互斥锁而不是读写锁。当读取锁保持很长时间时,读写锁才有意义;通过允许多个读取器同时位于临界区,原子操作增加的成本被可伸缩性改进所克服。
检测过多的缓存到缓存传输或伪共享可能很困难。一种有用的、相当可移植的技术是查看既引用内存又在其执行时间分析中突出显示的语句;如果大多数(非共享)加载没有错过缓存,则此方法效果良好。这个领域仍然是开发人员工具的一个很大程度上未开发的机遇;需要与编译器和运行时数据收集进行仔细集成才能正确解决此问题。
这份性能反模式列表绝非完整;但是,熟悉那些使我们的工作更具挑战性的问题应该有助于其他人避免它们——或者至少更快地识别它们。虽然并非我们所有的项目都可能拥有我们可能希望的性能资源,但避免这些反模式将使那些有限的资源更加有效。请记住,在项目开始时在基准测试、算法和数据结构选择方面完成的性能工作将在以后获得巨大的回报——也许足以让你避免在最后进行传统意义上的性能紧急补救。
BART SMAALDERS (http://blogs.sun.com/barts.) 在 Sun Microsystems 的内核组工作,致力于改进 Solaris 性能。他曾从事 OpenWindows、CDE 和 SunCluster 产品的工作。他是与 Steve Kleiman 和 Devang Shah 合著的《Programming with Threads》一书的作者之一。
最初发表于 Queue vol. 4, no. 1—
在 数字图书馆 中评论本文
David Collier-Brown - 你对应用程序性能一窍不通
你不需要在每次遇到性能或容量规划问题时都进行全面的基准测试。一个简单的测量将提供你系统的瓶颈点:这个示例程序在每个 CPU 每秒 8 个请求后会显着变慢。这通常足以告诉你最重要的事情:你是否会失败。
Peter Ward, Paul Wankadia, Kavita Guliani - 在 Google 重塑后端子集化
后端子集化对于降低成本很有用,甚至可能是在系统限制内运行所必需的。十多年来,Google 将确定性子集化作为其默认后端子集化算法,但尽管此算法平衡了每个后端任务的连接数,但确定性子集化具有很高的连接抖动水平。我们在 Google 的目标是设计一种具有降低连接抖动水平的算法,该算法可以取代确定性子集化作为默认后端子集化算法。
Noor Mubeen - 工作负载频率缩放定律 - 推导与验证
本文介绍了与每个 DVFS 子系统级别的工作负载利用率缩放相关的方程式。建立了频率、利用率和缩放因子(其本身随频率变化)之间的关系。这些方程式的验证结果被证明是棘手的,因为工作负载固有的利用率似乎也在治理样本的粒度上以未指定的方式变化。因此,应用了一种称为直方图脊线追踪的新方法。在将 DVFS 视为构建块时,量化缩放影响至关重要。典型应用包括 DVFS 管理器和或影响系统利用率、功耗和性能的其他层。
Theo Schlossnagle - DevOps 世界中的监控
监控可能看起来非常令人不知所措。最重要的是要记住,完美永远不应该是更好的敌人。DevOps 使组织内部能够进行高度迭代的改进。如果你没有监控,那就获取一些;获取任何东西。有些总比没有好,如果你已经接受了 DevOps,那么你已经签约在一段时间内使其变得更好。