Dave Cheney 是 Golang 社区最活跃和高产的成员之一,此文是 Dave Cheney 在大会上的分享,特意汉化出来。干货很多。

大纲

本次研讨会的目标是为你提供检测 Go 应用程序中的性能问题并修复它们所需的工具。

在这一天中,我们将从小处着手——学习如何编写基准,然后剖析一小段代码。然后讲讲执行跟踪器(tracer)、垃圾收集器(GC)和跟踪正在运行的应用程序。今天剩下的时间里,你将有机会提问,用你自己的代码进行实验。

主讲人

先决条件

今天你需要下载几个软件。

仓库

源码在 https://github.com/davecheney/high-performance-go-workshop

笔记本电脑、电源等。

讲义、代码基于 Go 1.12 版本

如果你已经升级到了 Go 1.13,也木有问题。在不同 Go 的小版本之间,总会有一些小的变化(指优化等)。我会在分享的过程中尽量指出这些变化。

Graphviz

关于 pprof 部分需要使用 graphviz 工具套件中的 dot 程序。

  • Linux: [sudo] apt-get install graphviz

  • OSX:

  • MacPorts: sudo port install graphviz

  • Homebrew: brew install graphviz

  • Windows (untested)

Google Chrome

执行追踪器(tracer)的部分需要 Google Chrome,不建议使用 Safari、Edge、Firefox 或 IE 。

1. 微处理器性能的过去,现在和未来

这是一个关于编写高性能代码的研讨会。在其他研讨会上,我讲的是解耦设计和可维护性,但我们今天在这里讨论是性能。

今天我想先简单讲讲我是如何思考计算机进化史的,以及为什么我认为编写高性能软件很重要。

实际上,软件是在运行在硬件上的,所以要讨论编写高性能代码,首先我们需要讨论运行代码的硬件。

1.1. Mechanical Sympathy (机械同感)

高性能 Go 代码工坊(Part1) - 图1
现在流行一个名词,你会听到像 Martin Thompson 和 Bill Kennedy 这样的人谈论“Mechanical Sympathy”(机械同感)。

“Mechanical Sympathy”(机械同感)这个名字来自伟大的赛车手 Jackie Stewart,他曾三次获得 F1 世界冠军。他认为最好的赛车手是对机器的工作原理有足够的了解,这样才能与机器和谐共处。

要成为一名优秀的赛车手,你不需要成为一名优秀的机械师,但你需要对车的工作原理有一个粗略的了解。

我相信作为软件工程师的我们也是如此。我想我们在座的各位都不会成为一名专业的 CPU 设计者,但这并不意味着我们可以忽视 CPU 设计者面临的问题。

1.2. 六个数量级

有一个大家经常看到的的梗图是这样的;
高性能 Go 代码工坊(Part1) - 图2
当然这是荒谬的,但它强调了计算机行业发生了多大的变化。

作为软件工程师,我们在座的所有人都受益于摩尔定律,40 年来,每 18 个月芯片上可用晶体管的数量翻一番。没有哪个行业在他们的工具上发展过程中有过六个数量级的改进。

但这一切都在改变。

1.3. 计算机还在变得越来越快吗?

所以,最根本的问题是,面对像上图这样的统计,我们是否应该问这样一个问题:计算机是否还在变得越来越快

如果计算机还在变快,那么也许我们不需要关心代码的性能,我们只需等硬件的发布,硬件厂商就会为我们解决性能问题。

1.3.1. 让我们看一下数据

这是很经典的数据,你可以在 John L. Hennessy 和 David A. Patterson 编写的《Computer Architecture, A Quantitative Approach》等教科书中找到。这图取自这本书的第5版。
高性能 Go 代码工坊(Part1) - 图3在第五版中,Hennessey 和 Patterson 认为机器性能有三个时代

  • 第一次是 20 世纪 70 年代和 80 年代初,那是计算机初步形成的时期。我们今天所知道的微处理器其实并不真正存在,计算机是由分立的晶体管或小规模集成电路构建的。成本、尺寸和对材料科学理解的局限性是限制因素。

  • 从 80 年代中期到 2004 年,趋势线很明显。计算机整体性能平均每年提高 52 %。计算机的功率每两年翻一番,因此人们把摩尔定律——晶粒上晶体管数量的翻番与计算机的性能混为一谈。

  • 然后我们进入了机器性能的第三个时代。速度就慢慢下来了。每年总的变化率是22%。

上面这个图是 2012 年的, 但幸运的是在 2012 年 Jeff Preshing 写了一个工具来抓取 Spec 网站并构建自己的图表。
高性能 Go 代码工坊(Part1) - 图4
所以这是用 1995 年到 2017 年的 Spec 数据做的同一张图。

在我看来,与其说我们在 2012 年的数据中看到了阶梯式的变化,不如说单核性能已经接近了一个极限。浮点的数据稍微好一点,但是对于我们在座的写业务程序的人来说,这个可能关系不大。

1.3.2. 是的,计算机的速度还是越来越快,慢慢的


The first thing to remember about the ending of Moore’s law is something Gordon Moore told me. He said “All exponentials come to an end”. —— John Hennessy

_关于摩尔定律的结局,首先要记住的是 Gordon Moore 告诉我的一件事。他说 “所有的指数都会有尽头”。  —— _John Hennessy

这是 Hennessy’s 在 Google Next 18 及其图灵奖演讲中的一段话。他的观点是,CPU性能仍在提高。但是,单线程整数性能仍在每年提高 2-3%左右。以这种速度,它将需要 20 年的复合增长才能使整数性能翻倍。相比之下,90 年代性能每两年翻一番。

为什么会出现这种情况?

1.4. 时钟速度

高性能 Go 代码工坊(Part1) - 图5
2015 年这张图很好地证明了这一点。最上面一行显示一个芯片上的晶体管数量。自 20 世纪 70 年代以来,趋势图一直大致呈线性增长。由于这是一个对数/直线关系图,因此这一线性序列代表指数级的增长。

然而,如果我们看中间这条线,我们看到时钟速度在十年内没有增加,而且 CPU 速度在 2004 年左右就停滞不前了。

下图显示的是散热功率,即热能的电功率,遵循相同的模式——时钟速度和 CPU 散热是相关的。

1.5. 热量

为什么 CPU 会产生热量?它是一个固态设备,没有移动的部件,像摩擦等效应并不(直接)相关。

这个取自 TI 的一份大数据表。在这个模型中,N 型器件中的开关被正电压上吸引,P 型器件被正电压排斥。高性能 Go 代码工坊(Part1) - 图6

CMOS 器件的功耗由三个因素共同组合而成的,这个房间里、你的桌子上和你口袋里的每个晶体管都由该器件构成。

  1. 静态功率。当晶体管是静态的,也就是说,不改变它的状态时,会有少量的电流通过晶体管泄漏到地上。晶体管越小,漏电就越多。漏电量随温度升高而增加。当你有几十亿个晶体管时,即使是微小的漏电量也会增加!

  2. 动态功率。当一个晶体管从一种状态转换到另一种状态时,它必须对其连接到栅极的各种电容进行充电或者是放电。每个晶体管的动态功率是电压的平方乘以电容和变化频率。降低电压可以降低晶体管的功耗,但较低的电压会导致晶体管的开关速度变慢。

  3. Crowbar,即短路电流。我们喜欢把晶体管看作是在原子上占据一种或另一种状态的数字器件,关或开。实际上,晶体管是模拟器件。作为一个开关,晶体管开始时大部分是关,然后转变或切换到通常处于开状态。这种转换或切换时间非常快,在现代处理器中约为皮(pico) 秒,但这仍代表着从Vcc到地有一段低电阻路径的时间。晶体管切换速度越快,其频率就越高,散热就越大。

1.6. Dennard 缩放比例定律的失效

要理解接下来发生了什么,我们需要看看 Robert H. Dennard 在1974年合著的一篇论文。Dennard 缩放比例定律大致指出,随着晶体管变小,它们的功率密度(power density)保持不变。更小的晶体管可以在较低的电压下运行,具有更低的栅电容,并且开关速度更快,这有助于减少动态功率的数量。

那么是怎么做到的呢?
高性能 Go 代码工坊(Part1) - 图7
结果不是很理想。当晶体管的栅极长度接近几个硅原子的宽度时,晶体管的尺寸、电压和重要的漏电之间的关系就打破了。

1999年 micro-32 会议上有人曾假设,如果我们遵循时钟速度增加和晶体管尺寸缩小的趋势线,那么在一代处理器内,晶体管结将接近核反应堆核心的温度。很明显,这是一个疯狂的说法。奔腾 4 标志着单核、高频、消费型 CPU 产品线的终结

回到这个图上,我们看到时钟速度停滞不前的原因是因为 cpu 的冷却能力超过了我们的能力。到 2006 年,减小晶体管的尺寸不再能提高其功率效率了。

我们现在知道,CPU 功能尺寸的缩减主要是为了降低功耗。降低功耗不仅仅意味着环保(green),就像循环利用,拯救地球。主要目标是保持功耗和散热保持在损坏 CPU 的水平之下
高性能 Go 代码工坊(Part1) - 图8但是,图中有一部分还在继续增长,那就是一个芯片上晶体管的数量。cpu 发展的特点是体积大,在同一区域有更多的晶体管,既有正面的影响,也有负面的影响。

而且,正如你在图中看到的,每个晶体管的成本一直在下降,直到 5 年前左右,每个晶体管的成本又开始回升。高性能 Go 代码工坊(Part1) - 图9
不仅制造更小的晶体管越来越贵,而且越来越难。这份来自 2016 年的报告显示了 2013 年芯片制造商认为会发生的预测;两年后,他们错过了所有的预测,虽然我没有这份报告的更新版本,但没有迹象表明他们能够扭转这一趋势。

英特尔、台积电、AMD 和三星的成本高达数十亿美元,因为它们必须建立新的晶圆厂,购买所有新的工具。因此,虽然每个芯片的晶体管数量在不断增加,但它们的单位成本已经开始增加。

Even the term gate length, measured in nano meters, has become ambiguous. Various manufacturers measure the size of their transistors in different ways allowing them to demonstrate a smaller number than their competitors without perhaps delivering. This is the Non-GAAP Earning reporting model of CPU manufacturers.
甚至以纳米为单位的术语“gate length”(栅极长度)也变得模棱两可。各种制造商以不同的方式测量其晶体管的尺寸,从而使它们在没有交付的情况下可以展示比竞争对手少的数量。这是 CPU 制造商的 Non-GAAP 盈利报告模式。

1.7. 更多的核心

y5cdp7nhs2uy.jpg
随着散热和频率的限制,再也不可能使单个核心运行的速度提高一倍。但是,如果你再添加一个核心,你可以提供两倍的处理能力 —— 如果软件可以支持它。

事实上,CPU 的核心数量是由散热主导的。Dennard 缩放比例定律的失效意味着 CPU 的时钟速度是 1 到 4 Ghz 之间的任意数字,这取决于它的热度。我们将在讨论基准时会看到这一点。

1.8. Amdahl’s law(阿姆达尔定律)

CPU 的速度并没有变快,但随着超线程和多核技术的发展,CPU 变得越来越宽。双核在移动设备上,四核在桌面设备上,几十核在服务器设备上。这将是计算机性能的未来吗?可惜不是。

Amdahl’s law(阿姆达尔定律),是以 IBM/360 的设计师 Gene Amdahl 名字命名的,它是一个公式,给出了在固定工作负载下,任务执行延迟的理论速度,可以预期一个资源得到改善的系统。高性能 Go 代码工坊(Part1) - 图11
阿姆达尔定律告诉我们,程序的最大加速受程序的顺序部分限制。如果你写一个程序,其95%的执行量能够并行运行,即使有上千个处理器,程序执行的最大速度也限制在 20 倍。

想一想你每天工作的程序,其执行中有多少是可以并行的?

1.9. 动态优化

时钟速度停滞不前,而将额外的核心投入到这个问题上的回报有限,那么速度的提升从何而来?它们来自芯片本身的架构改进。这些是五到七年的大项目,像 Nehalem, Sandy Bridge, and Skylake.

在过去二十年中,性能的改善主要来自于架构的改进:

1.9.1. 乱序执行

乱序,也称为超标量体系结构,是一种从 CPU 正在执行的代码中提取所谓指令级并行性的方法。现代 cpu 在硬件级有效地执行 SSA,以识别操作之间的数据依赖性,并在可能的情况下并行运行独立的指令。

然而,任何一段代码中固有的并行性数量都是有限制的。这也是非常耗电的。大多数现代 CPU 已经确定每个核心有6个执行单元,因为在流水线的每个阶段,将每个执行单元与所有其他单元连接起来有一个 n 平方的成本。

1.9.2. 推测执行

除了最小的微控制器,所有 CPU 都使用指令流水线在指令提取/解码/执行/提交周期中重叠部分。
高性能 Go 代码工坊(Part1) - 图12
指令流水线的问题是分支指令,平均每 5-8 条指令就会发生一次。当 CPU 到达分支时,它不能从分支之外寻找额外的指令来执行,在它知道程序计数器也会在哪里分支之前,它不能开始填充它的流水线。推测执行允许 CPU 在分支指令还在处理的时候就 “猜测 “分支会走哪条路径!

如果 CPU 正确地预测了分支,那么它就可以保持它的指令流水线的充盈。如果CPU不能预测正确的分支,那么当它意识到错误时,它必须回滚对其架构状态所做的任何更改。正如我们从幽灵漏洞学习到那样,有时候,这种回滚并非如预期那样天衣无缝。

当分支预测率较低时,推测执行消耗可能非常大。如果分支预测错误了,CPU 不仅必须回溯到错误预测的起点,而且在错误分支上的消耗也会被浪费。

所有这些优化都带来了单线程性能的改进,但代价是大量的晶体管和功率。

Cliff Click has a wonderful presentation that argues out of order and speculative execution is most useful for starting cache misses early thereby reducing observed cache latency.

Cliff Click 有一个很棒的演讲,他认为乱序和推测执行对于提前启动缓存未命中非常有用,从而减少观察到的缓存延迟。

1.10. 现代 CPU 已针对批量操作进行了优化

Modern processors are a like nitro fuelled funny cars, they excel at the quarter mile. Unfortunately modern programming languages are like Monte Carlo, they are full of twists and turns. — David Ungar 现代处理器就像硝基燃料的搞笑的车,它们擅长于四分之一英里处表现出色。不幸的是,现代编程语言就像蒙特卡洛一样,它们充满了曲折。 —— David Ungar_

这是 David Ungar 的一句话,他是一位非常有影响力的计算机科学家,是 SELF 编程语言的开发者,我在网上找到的一份非常古老的演示文稿中提到了这一点。

因此,现代的 CPU 是针对批量传输和批量操作进行优化的。在每个层面上,操作的设置成本都会鼓励你批量工作。一些例子包括

  • 内存不是按字节加载的,而是按多条高速缓存线加载的,这就是为什么对齐方式比早期计算机中的对齐方式变得不那么重要的原因。

  • 像 MMX 和 SSE 这样的向量指令允许一条指令对多个数据项并行执行,前提是你的程序可以用这种形式表示。

1.11. 现代处理器受到内存延迟而不是内存容量的限制

如果 CPU 领域的情况还不够糟糕,那么来自内存方面的消息也好不到哪里去。

连接到服务器的物理内存呈几何级数增长。上世纪 80 年代,我的第一台电脑有千字节的内存。当我读高中的时候,我所有的论文都是在一台内存为 1.8 MB 的 386 电脑上写的。现在,找到拥有数十或数百 GB 运行内存的服务器已经是司空见惯的事情了,而云提供商正在向 TB 的运行内存进军。
image.png
然而,处理器速度和内存访问时间之间的差距继续扩大。
BmBr2mwCIAAhJo1.png
但是,从等待内存所损失的处理器周期来看,物理内存还是一如既往的遥遥无期,因为内存没有跟上 CPU 速度的增长。

所以,大多数现代处理器受限于内存延迟而非容量。

1.12. 缓存统治着我周围的一切

高性能 Go 代码工坊(Part1) - 图15
几十年来,处理器/内存上限的解决方案是添加一个高速缓存 —— 一块靠近CPU的小型快速内存,现在直接集成在CPU上。

但是

  • L1 几十年来一直停留在每个核心 32kb

  • 在英特尔最大的部件上,L2 已缓慢攀升至 512kb

  • L3 现在在 4-32mb 范围内,但其访问时间是可变的

高性能 Go 代码工坊(Part1) - 图16
由于缓存的大小受到限制,因为它们在 CPU 芯片的物理尺寸很大,消耗大量的资源。若要将缓存未命中率减半,必须将缓存大小翻四倍。

1.13. 免费午餐结束了

2005 年,C++ 委员会的 leader ,Herb Sutter 写了一篇题为 The free lunch is over(免费午餐已经结束)的文章。Sutter 在他的文章中讨论了我提到的所有观点,并断言未来的程序员将不再能够依赖更快的硬件来修复速度很慢的程序——或者说速度很慢的编程语言。

十多年后的今天,毫无疑问,Herb Sutter 是对的。内存太慢,缓存太小,CPU 时钟速度在倒退,单线程 CPU 的简单世界早已不复存在。

摩尔定律仍然有效,但对在座的所有人来说,免费午餐已经结束。

1.14. 结论

The numbers I would cite would be by 2010: 30GHz, 10billion transistors, and 1 tera-instruction per second. — Pat Gelsinger, Intel CTO, April 2002

我会引用的数字是到 2010 年:30 GHz,100 亿个晶体管,每秒 1TB 指令。 —— Pat Gelsinger,英特尔首席技术官,2002 年 4 月

很显然,如果材料科学没有突破,CPU 性能恢复到 52% 的年增长率的时代可能性是微乎其微的。普遍的共识是,问题不在于材料科学本身,而在于晶体管的使用方式。用硅片表示的顺序指令流的逻辑模型导致了这种昂贵的局面。

网上有很多 ppt 重新阐述了这一点。他们都有相同的预测——未来的计算机不会像现在这样编程。有人认为,它看起来更像是带有数百个非常愚蠢、非常不连贯的处理器组成的显卡。另一些人则认为,超长指令字(VLIW)计算机将占主导地位。所有人都同意,我们目前的顺序编程语言将无法与这些类型的处理器兼容。

我的观点是,这些预测是正确的,硬件制造商在这一点上拯救我们的前景是严峻的。然而,我们有很大的空间来优化我们现在的硬件编写的程序。Rick Hudson 在 2015 年的Gophercon 大会上谈到了重新参与软件的“virtuous cycle”(良性循环),这种软件可以与我们现在的硬件一起工作,而不是与之无关。

从我之前展示的图表来看,从 2015 年到 2018 年,在整数性能最多提高 5-8% 的情况下,Go 团队将垃圾收集器暂停时间降低了两个数量级。Go 1.11 版本的程序的 GC 延迟明显好于使用 Go 1.6 版本在相同硬件上的相同程序。这些优化都不是来自硬件。

因此,为了在当今世界的硬件上获得最佳性能,你需要一种编程语言:

  • 是编译型的,而不是解释型的,因为解释型编程语言与 CPU 分支预测和推测执行的交互性很差。

  • 你需要一种允许高效编写代码的语言,它需要能够有效地控制比特和字节,以及整数的长度,而不是假装每个数字都是一个理想的浮点数。

  • 你需要一种允许程序员有效地控制内存的语言,想想 struct 和 Java objects,因为所有的指针追逐都会给 CPU 高速缓存带来压力,而缓存未命中会消耗数百个周期。

  • 一门可以扩展到多核的编程语言,因为应用程序的性能是由它如何有效地使用缓存和如何有效地在多核上并行工作决定的。

很明显我们在这里讨论的是 Go ,我相信 Go 符合了我刚才描述的许多特点。

1.14.1. 这对我们意味着什么?

There are only three optimizations: Do less. Do it less often. Do it faster. The largest gains come from 1, but we spend all our time on 3. — Michael Fromberger

只有三个优化。少做。减少做的次数。做得更快。 最大的收益来自于 1,但我们把所有的时间都花在了 3 上。 —— Michael Fromberger

这个讲座的目的是想说明,当你在谈论一个程序或系统的性能时,完全在于软件。等待更快的硬件来拯救世界是一种愚蠢的行为。

但是有一个好消息,我们可以在软件上做大量的改进,这就是我们今天要讲的内容。

1.14.2. 进一步阅读