高性能Golang研讨会【精】
by Dave Cheney
概观
本次研讨会的目标是为您提供诊断Go应用程序中的性能问题并进行修复所需的工具。
通过这一天,我们将从小工作 - 学习如何编写基准,然后分析一小段代码。然后走出去讨论执行跟踪器,垃圾收集器和跟踪运行的应用程序。剩下的时间将是您提出问题的机会,并尝试使用您自己的代码。
您可以在此处找到此演示文稿的最新版本 |
欢迎
你好,欢迎光临!🎉
本次研讨会的目标是为您提供诊断Go应用程序中的性能问题并进行修复所需的工具。
通过这一天,我们将从小工作 - 学习如何编写基准,然后分析一小段代码。然后走出去讨论执行跟踪器,垃圾收集器和跟踪运行的应用程序。剩下的时间将是您提出问题的机会,并尝试使用您自己的代码。
教师
-
戴夫·切尼dave@cheney.net
先决条件
这是您今天需要的几个软件下载。
研讨会资料库
将源代码下载到本文档并在https://github.com/davecheney/high-performance-go-workshop上编写代码示例
的Graphviz
关于pprof的部分要求dot
程序随graphviz
工具套件一起提供。
-
Linux的:
[sudo] apt-get install graphviz
-
OSX:
-
MacPorts的:
sudo port install graphviz
-
macx:
brew install graphviz
-
Windows(未经测试)
您自己的代码来分析和优化
当天的最后一部分将是一个开放式会议,您可以在其中试验您学到的工具。
1.微处理器性能的过去,现在和未来
这是一个关于编写高性能代码的研讨会。在其他研讨会上,我谈到了解耦设计和可维护性,但我们今天在这里谈论性能。
我想今天开始简短的讲座,讲述我如何看待计算机发展的历史,以及为什么我认为编写高性能软件很重要。
现实情况是软件在硬件上运行,所以谈到编写高性能代码,首先我们需要讨论运行代码的硬件。
1.1。理解机械

目前有一个流行的术语,你会听到Martin Thompson或Bill Kennedy等人谈论“机械上的理解”。
“机械理解”这个名字来自伟大的赛车手杰基斯图尔特,他是世界一级方程式赛车冠军的3倍。他相信最好的车手对机器如何工作有足够的了解,因此他们可以与之协调工作。
要成为一名优秀的赛车手,你不需要成为一名出色的机械师,但你需要对马车的工作方式有一个粗略的了解。
我相信我们作为软件工程师也是如此。我认为这个会议室里的任何人都不会成为专业的CPU设计师,但这并不意味着我们可以忽略CPU设计人员面临的问题。
1.2。六个数量级
有一个常见的互联网模因,就像这样;

当然这是荒谬的,但它强调了计算行业的变化。
作为软件作者,我们这个房间里的所有人都受益于摩尔定律,40年来,芯片每18个月可用晶体管数量翻了一番。没有其他行业在一生的空间中经历了六个数量级的工具改进[ 1 ]。
但这一切都在改变。
1.3。电脑还在变快吗?
因此,基本问题是,面对上图中的统计数据,我们应该问的问题是计算机是否仍然变得更快?
如果计算机仍然变得越来越快,那么我们可能不需要关心代码的性能,我们只需稍等一下,硬件制造商将为我们解决性能问题。
1.3.1。我们来看看数据
这是您在教科书中找到的经典数据,如计算机体系结构, John L. Hennessy和David A. Patterson的定量方法。该图取自第5版

在第5版中,Hennessey和Patterson认为计算性能有三个时代
-
第一个是1970年代和80年代初,这是形成时期。我们今天所知的微处理器并不存在,计算机是用分立晶体管或小规模集成电路构建的。成本,规模和对材料科学理解的限制是限制因素。
-
从80年代中期到2004年,趋势线很明显。计算机整数性能平均每年提高52%。计算机功率每两年翻一番,因此人们将摩尔定律与计算机性能相加,即模具上晶体管数量增加一倍。
-
然后我们来到计算机性能的第三个时代。改进变慢了。总变化率为每年22%。
之前的图表仅上升到2012年,但幸运的是在2012年,Jeff Preshing编写了一个工具来抓取Spec网站并构建自己的图表。

所以这是使用1995年至2017年的Spec数据的相同图表。
对我来说,不是我们在2012年的数据中看到的阶段变化,而是说单核心性能接近极限。对于浮点数而言,这些数字略好一些,但对于我们在会议室中进行业务线应用程序而言,这可能并不相关。
1.3.2。是的,电脑仍然变得越来越快
关于摩尔定律结束的第一件事就是戈登摩尔告诉我的事情。他说“所有指数都结束了”。- 约翰轩尼诗
这是轩尼诗引用Google Next 18和他的图灵奖演讲。他的论点是肯定的,CPU性能仍在提高。但是,单线程整数性能仍在每年提高2-3%左右。按此速度,它将需要20年的复合增长才能达到整数表现。相比之下,90年代的表现每两年增加一倍。
为什么会这样?
1.4。时钟速度

2015年的图表很好地证明了这一点。顶行显示了芯片上的晶体管数量。自1970年代以来,这一趋势在一个大致线性的趋势线上继续。由于这是log / lin图,因此该线性系列代表指数增长。
然而,如果我们看一下中间线,我们看到时钟速度在十年内没有增加,我们看到cpu速度在2004年左右停滞不前
下图显示了散热功率; 即电能变成热量,遵循相同的模式 - 时钟速度和cpu散热是相关的。
1.5。热量
为什么CPU产生热量?它是一个固态设备,没有移动组件,所以摩擦等效果在这里并没有(直接)相关。

CMOS器件的功耗,就是这个房间里的每个晶体管,桌面和口袋里的三个因素的组合。
-
静电。当晶体管静止时,即不改变其状态时,有少量电流通过晶体管泄漏到地。晶体管越小,泄漏越多。泄漏随温度升高而增加。当你拥有数十亿个晶体管时,即使是少量的泄漏也会增加!
-
动力。当晶体管从一种状态转换到另一种状态时,它必须对连接到栅极的各种电容充电或放电。每个晶体管的动态功率是电容的平方乘以电容和变化的频率。降低电压可以降低晶体管消耗的功率,但是较低的电压会导致晶体管切换较慢。
-
撬棍或短路电流。我们喜欢将晶体管视为数字设备占据一个或另一个状态,原子地关闭或打开。实际上,晶体管是模拟器件。作为开关,晶体管大部分开始关断,并且转换或切换到大部分开启的状态。这种转换或切换时间非常快,在现代处理器中它的速度为皮秒,但仍然代表从Vcc到地的低电阻路径的一段时间。晶体管开关越快,其频率越高,散热量就越大。
1.6。Dennard缩放的结束
为了理解接下来发生的事情,我们需要查看1974年由Robert H. Dennard共同撰写的论文。Dennard的Scaling定律大致指出随着晶体管变小,它们的功率密度保持不变。较小的晶体管可以在较低的电压下运行,具有较低的栅极电容,并且开关速度更快,这有助于减少动态功率。
那怎么办呢?

结果并不那么好。随着晶体管的栅极长度接近几个硅原子的宽度,晶体管尺寸,电压和重要的泄漏之间的关系被破坏。
在1999年的Micro-32会议上假设,如果我们遵循时钟速度增加和晶体管尺寸缩小的趋势线,那么在处理器生成中,晶体管结将接近核反应堆核心的温度。显然这是疯狂的。奔腾4 标志着单核,高频,消费类CPU 的终结。
回到这个图表,我们看到时钟速度停滞的原因是因为cpu超出了我们冷却它们的能力。到2006年,减小晶体管的尺寸不再提高其功率效率。
我们现在知道降低CPU特征尺寸主要是为了降低功耗。降低能耗并不仅仅意味着“绿色”,就像回收一样,拯救地球。主要目标是将功耗和热耗散保持在低于损坏CPU的水平。

但是,图表的一部分仍在继续增加,即芯片上的晶体管数量。cpu的行进特征是在相同的给定区域中具有更大的晶体管,具有正面和负面效果。
此外,正如您在插页中看到的那样,每个晶体管的成本持续下降,直到大约5年前,然后每个晶体管的成本开始再次回升。

创建更小的晶体管不仅成本越来越高,而且越来越难。2016年的这份报告显示了2013年芯片制造商认为会发生什么的预测; 两年后,他们错过了所有的预测,虽然我没有这份报告的更新版本,但没有迹象表明他们能够扭转这种趋势。
英特尔,台积电,AMD和三星花费数十亿美元,因为他们必须建立新的晶圆厂,购买所有新的工艺工具。因此,虽然每个芯片的晶体管数量持续增加,但其单位成本已开始增加。
甚至术语门长度(以纳米为单位)也变得模棱两可。各种制造商以不同的方式测量晶体管的尺寸,使其能够展示比竞争对手更小的数量,而无需提供。这是CPU制造商的非GAAP收益报告模型。 |
1.7。更多核心(more cores)

由于达到了热量和频率限制,因此不再能够使单核运行速度提高两倍。但是,如果添加其他内核,则可以提供两倍的处理能力 - 如果软件可以支持它。
实际上,CPU的核心数量主要是散热。Dennard缩放的结束意味着CPU的时钟速度是1到4 Ghz之间的任意数字,具体取决于它的热度。当我们谈论基准测试时,我们会很快看到这一点。
1.8。阿姆达尔定律
CPU不会变得越来越快,但随着超线程和多核的发展,它们的范围越来越广。移动部件上的双核,桌面部件上的四核,服务器部件上的数十个核心。这将成为计算机性能的未来吗?不幸的是。
Amdahl定律以IBM / 360的设计者Gene Amdahl命名,是一个公式,它给出了在固定工作负载下执行任务的延迟的理论加速,这可以预期资源得到改善的系统。
Amdahl定律告诉我们,程序的最大加速时间受程序的连续部分的限制。如果编写一个程序,其95%的执行能够并行运行,即使有数千个处理器,程序执行的最大加速也限制为20倍。
想想你每天工作的程序,他们的执行程序有多少是可以分开的?
1.9。动态优化
随着时钟速度的停滞以及在问题上抛出额外核心的回报有限,加速从何而来?它们来自芯片本身的架构改进。这些是具有Nehalem,Sandy Bridge和Skylake等名称的五到七年大型项目。
在过去二十年中,性能的大部分提升来自于体系结构的改进:
1.9.1。乱序执行
乱序,也称为超标量,执行是一种从CPU执行的代码中提取所谓的指令级并行性的方法。现代CPU在硬件级别有效地执行SSA以识别操作之间的数据依赖性,并且在可能的情况下并行地运行独立指令。
但是,任何一段代码中固有的并行数量都是有限的。它也非常耗电。大多数现代CPU已经确定每个核心有六个执行单元,因为在管道的每个阶段都有一个n平方成本将每个执行单元连接到所有其他执行单元。
1.9.2。投机执行
保存最小的微控制器,所有CPU利用指令流水线重叠指令获取/解码/执行/提交周期中的部分。

指令流水线的问题是分支指令,平均每5-8条指令发生一次。当CPU到达分支时,它无法查看分支以外的其他指令来执行,并且它无法开始填充其管道,直到它知道程序计数器也将分支到何处。推测执行允许CPU“猜测” 分支指令仍在处理时分支将采用哪条路径!
如果CPU正确预测分支,那么它可以保持其指令管道满。如果CPU无法预测正确的分支,那么当它意识到错误时,它必须回滚对其架构状态所做的任何更改。由于我们都在学习Spectre风格的漏洞,有时这种回滚并不像希望的那样无缝。
当分支预测率低时,推测执行可能非常耗电。如果分支是错误预测的,那么CPU不仅必须回溯到错误预测的点,而且浪费在错误分支上的能量。
所有这些优化都导致了我们所见的单线程性能的提高,代价是大量的晶体管和功率。
Cliff Click有一个精彩的演示文稿,它不按顺序进行,而且推测性执行对于尽早启动缓存未命中非常有用,从而减少了观察到的缓存延迟。 |
1.10。现代CPU针对批量操作进行了优化
现代处理器就像硝基燃料的有趣汽车,它们在四分之一英里表现出色。不幸的是,现代编程语言就像蒙特卡罗,它们充满了曲折。 - 大卫Ungar
引自David Ungar,一位有影响力的计算机科学家和SELF编程语言的开发人员,我在网上找到了一个非常古老的演示文稿。
因此,现代CPU针对批量传输和批量操作进行了优化。在每个级别,操作的设置都会鼓励您批量工作。一些例子包括
-
内存不是每个字节加载,而是每多个缓存行加载,这就是为什么对齐变得比以前的计算机更少的问题。
-
像MMX和SSE这样的向量指令允许单个指令同时针对多个数据项执行,前提是您的程序可以以该形式表示。
1.11。现代处理器受内存延迟而非内存容量的限制
如果CPU的情况不够糟糕,那么来自房子内存方面的消息就不会好多了。
连接到服务器的物理内存几何增加。我在1980年代的第一台计算机有千字节的内存。当我上高中的时候,我写的所有论文都是386,有1.8兆字节的公羊。现在,它常常找到具有数十或数百GB RAM的服务器,而云提供商正在推动数TB的内存。

但是,处理器速度和内存访问时间之间的差距仍在继续增长。

但是,就等待内存而丢失的处理器周期而言,物理内存仍然遥不可及,因为内存跟不上CPU速度的增长。
因此,大多数现代处理器都受到内存延迟而非容量的限制。
1.12。缓存规则我周围的一切

几十年来,处理器/内存上限的解决方案是添加一个缓存 - 一块靠近CPU的小型快速内存,现在直接集成到CPU上。
但;
-
几十年来,L1一直停留在每核心32kb
-
L2在最大的英特尔部分上缓慢爬升至512kb
-
L3现在在4-32mb范围内测量,但其访问时间是可变的

1.13。免费午餐结束了
2005年,C ++委员会领导人Herb Sutter撰写了一篇题为“免费午餐结束”的文章。在他的文章中,Sutter讨论了我所涵盖的所有要点,并断言未来的程序员将不再能够依赖更快的硬件来修复慢速程序或减慢编程语言。
现在,十多年后,毫无疑问Herb Sutter是对的。内存很慢,缓存太小,CPU时钟速度倒退,而单线程CPU的简单世界早已不复存在。
摩尔定律仍然有效,但对于我们这个房间里的所有人来说,免费午餐已经结束了。
1.14。结论
我要引用的数字将是2010年:30GHz,100亿个晶体管和每秒1个tera指令。- 英特尔首席技术官Pat Gelsinger,2002年4月
很明显,如果没有材料科学的突破,那么回归到CPU性能同比增长52%的日子的可能性就会非常小。共同的共识是,错误不在于材料科学本身,而在于如何使用晶体管。以硅表示的顺序指令流的逻辑模型导致了这种昂贵的终结。
网上有很多演示文稿重申了这一点。他们都有相同的预测 - 未来的计算机将不会像今天这样编程。一些人认为它看起来更像是具有数百个非常愚蠢,非常不连贯的处理器的显卡。其他人认为,超长指令字(VLIW)计算机将成为主流。所有人都同意我们目前的顺序编程语言与这些类型的处理器不兼容。
我认为这些预测是正确的,硬件制造商在这一点上拯救我们的前景是严峻的。但是,今天我们为今天的硬件编写的程序有很大的优化空间。Rick Hudson在GopherCon 2015上发表了关于重新使用软件的“良性循环”的说法,该软件与我们今天的硬件配合使用,而不是它的不一致。
看看我之前展示的图表,从2015年到2018年,整数性能提升了5-8%,而且内存延迟时间更少,Go团队将垃圾收集器暂停时间减少了两个数量级。Go 1.11程序显示出比使用Go 1.6在相同硬件上的相同程序明显更好的GC延迟。这些都不是来自硬件。
因此,为了在当今世界的当今硬件上获得最佳性能,您需要一种编程语言:
-
是编译的,而不是解释的,因为解释的编程语言与CPU分支预测器和推测执行的交互性很差。
-
您需要一种允许编写高效代码的语言,它需要能够有效地讨论位和字节以及整数的长度,而不是假装每个数字都是理想的浮点数。
-
你需要一种语言让程序员有效地讨论内存,思考结构与java对象,因为所有指针追逐都会给CPU缓存带来压力,而缓存未命中会烧掉数百个周期。
-
作为应用程序性能而扩展到多个核心的编程语言取决于它使用其缓存的效率以及它在多个核心上并行工作的效率。
显然我们在这里谈论Go,我相信Go继承了我刚才描述的许多特征。
1.14.1。这对我们意味着什么?
只有三个优化:少做。少做一些。做得更快。
最大的收益来自1,但我们将所有时间都花在了3上。 - Michael Fromberger
本讲座的目的是说明当你谈论程序或系统的性能完全在软件中时。等待更快的硬件来挽救这一天是一个愚蠢的错误。
但有一个好消息,我们可以在软件方面做出一些改进,这就是我们今天要讨论的内容。
1.14.2。进一步阅读
-
微处理器的未来,Sophie Wilson JuliaCon 2018
-
计算的未来:与John Hennessy的对话 (Google I / O '18)
2.基准测试
测量两次并切一次。 - 古老的谚语
在我们尝试提高一段代码的性能之前,首先我们必须知道它当前的性能。
本节重点介绍如何使用Go测试框架构建有用的基准测试,并提供避免陷阱的实用技巧。
2.1。基准规则基准
在进行基准测试之前,您必须拥有稳定的环境才能获得可重复的结果。
-
机器必须处于空闲状态 - 不要在共享硬件上进行配置,不要在等待长基准运行时浏览网页。
-
注意省电和热缩放。这些在现代笔记本电脑上几乎是不可避免的。
-
避免虚拟机和共享云托管; 对于一致的测量,它们可能太嘈杂。
如果您负担得起,请购买专用的性能测试硬件。机架,禁用所有电源管理和热缩放,永不更新这些机器上的软件。从系统管理的角度来看,最后一点是糟糕的建议,但如果软件更新改变了内核或库执行的方式 - 想想Spectre补丁 - 这将使之前的任何基准测试结果无效。
对于我们其他人来说,有一个前后样本并多次运行它们以获得一致的结果。
2.2。使用测试包进行基准测试
该testing
软件包内置支持编写基准测试。如果我们有这样一个简单的函数:
func Fib(n int) int {
switch n {
case 0:
return 0
case 1:
return 1
case 2:
return 2
default:
return Fib(n-1) + Fib(n-2)
}
}
我们可以使用该testing
包为该函数编写函数的基准。
func BenchmarkFib20(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib(20) // run the Fib function b.N times
}
}
基准函数与_test.go 文件中的测试一起存在。 |
基准测试类似于测试,唯一真正的区别是他们需要的是一个*testing.B
而不是一个*testing.T
。这两种类型的实现testing.TB
提供类似的人群的最爱接口Errorf()
,Fatalf()
和FailNow()
。
2.2.1。运行包的基准
基准测试使用testing
它们通过go test
子命令执行它们。但是,默认情况下,在您调用时go test
,将排除基准。
要在包中显式运行基准测试,请使用-bench
标志。-bench
采用与您要运行的基准测试名称相匹配的正则表达式,因此调用包中所有基准测试的最常用方法是-bench=.
。这是一个例子:
% go test -bench=. ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8 30000 40865 ns/op
PASS
ok _/Users/dfc/devel/high-performance-go-workshop/examples/fib 1.671s
|
2.2.2。基准测试的工作原理
每个基准函数都被调用不同的值b.N
,这是基准应该运行的迭代次数。
b.N
从1开始,如果基准函数在1秒内完成 - 默认值 - 然后b.N
增加,基准函数再次运行。
b.N
大致顺序增加; 1,2,3,5,10,20,30,50,100等。基准测试框架试图变得聪明,如果它看到较小的值b.N
相对较快地完成,它将更快地增加迭代次数。
看看上面的例子,BenchmarkFib20-8
发现循环的大约30,000次迭代只需要一秒钟。从那里开始,基准框架计算出每次操作的平均时间为40865ns。
所述
这显示了使用1,2和4核运行基准测试。在这种情况下,该标志对结果几乎没有影响,因为该基准是完全顺序的。 |
2.2.3。提高基准精度
该fib
函数是一个稍微有点人为的例子 - 除非您编写TechPower Web服务器基准测试 - 您的业务不太可能被计算在计算Fibonaci序列中第20个数字的速度。但是,基准测试确实提供了有效基准的忠实示例。
具体而言,您希望您的基准测试运行数万次迭代,以便您获得每次操作的良好平均值。如果您的基准测试仅运行100次或10次迭代,则这些运行的平均值可能具有较高的标准偏差。如果您的基准测试运行数百万或数十亿次迭代,平均值可能非常准确,但受到代码布局和对齐的影响。
为了增加迭代次数,可以使用-benchtime
标志增加基准时间。例如:
% go test -bench=. -benchtime=10s ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib20-8 300000 39318 ns/op
PASS
ok _/Users/dfc/devel/high-performance-go-workshop/examples/fib 20.066s
b.N
跑到相同的基准测试,直到它达到一个超过10秒的返回值。当我们运行10倍以上时,迭代总数会增加10倍。结果没有太大变化,这是我们的预期。
为什么报告的总时间为20秒,而不是10秒?
如果你有一个运行毫安或数十亿迭代的基准测试,导致微操作或纳秒范围内的每个操作的时间,你可能会发现你的基准数字不稳定,因为热缩放,内存局部性,后台处理,gc活动等。
对于每次操作10或单个数字纳秒的时间,指令重新排序和代码对齐的相对论效应将对您的基准时间产生影响。
要使用-count
标志多次处理此运行基准测试:
% go test -bench=Fib1 -count=10 ./examples/fib/
goos: darwin
goarch: amd64
BenchmarkFib1-8 2000000000 1.99 ns/op
BenchmarkFib1-8 1000000000 1.95 ns/op
BenchmarkFib1-8 2000000000 1.99 ns/op
BenchmarkFib1-8 2000000000 1.97 ns/op
BenchmarkFib1-8 2000000000 1.99 ns/op
BenchmarkFib1-8 2000000000 1.96 ns/op
BenchmarkFib1-8 2000000000 1.99 ns/op
BenchmarkFib1-8 2000000000 2.01 ns/op
BenchmarkFib1-8 2000000000 1.99 ns/op
BenchmarkFib1-8 1000000000 2.00 ns/op
基准测试Fib(1)
需要大约2纳秒,方差为+/- 2%。
Go 1.12中的新-benchtime
标志现在需要进行多次迭代,例如。-benchtime=20x
这将完全运行您的代码benchtime
。
尝试使用-benchtime
10x,20x,50x,100x和300x 运行上面的fib台。你看到了什么?
如果您发现go test 需要针对特定软件包调整适用的默认值,我建议将这些设置编成一个,Makefile 以便每个想要运行基准测试的人都可以使用相同的设置进行编码。 |
2.3。将基准与benchstat进行比较
在上一节中,我建议不止一次运行基准测试以获得更多数据。由于我在本章开头提到的电源管理,后台进程和热管理的影响,这对任何基准测试都是很好的建议。
我将介绍Russ Cox的一个名为benchstat的工具。
% go get golang.org/x/perf/cmd/benchstat
Benchstat可以采取一系列基准测试,并告诉您它们的稳定性。这是Fib(20)
关于电池电量的示例。
% go test -bench=Fib20 -count=10 ./examples/fib/ | tee old.txt
goos: darwin
goarch: amd64
BenchmarkFib20-8 50000 38479 ns/op
BenchmarkFib20-8 50000 38303 ns/op
BenchmarkFib20-8 50000 38130 ns/op
BenchmarkFib20-8 50000 38636 ns/op
BenchmarkFib20-8 50000 38784 ns/op
BenchmarkFib20-8 50000 38310 ns/op
BenchmarkFib20-8 50000 38156 ns/op
BenchmarkFib20-8 50000 38291 ns/op
BenchmarkFib20-8 50000 38075 ns/op
BenchmarkFib20-8 50000 38705 ns/op
PASS
ok _/Users/dfc/devel/high-performance-go-workshop/examples/fib 23.125s
% benchstat old.txt
name time/op
Fib20-8 38.4µs ± 1%
benchstat
告诉我们平均值为38.8微秒,样本间的变化为+/- 2%。这对电池电量非常好。
-
第一次运行是最慢的,因为操作系统的CPU时钟已经降低以节省功耗。
-
接下来的两次运行是最快的,因为操作系统决定这不是一个短暂的工作峰值,它提高了时钟速度,以尽快通过工作,希望能够返回睡觉。
-
其余的运行是用于产热的操作系统和bios交易功耗。
2.3.1。提高Fib
确定两组基准测试之间的性能差异可能是单调乏味且容易出错的。Benchstat可以帮助我们解决这个问题。
保存基准运行的输出很有用,但您也可以保存生成它的二进制文件。这使您可以重新运行基准测试以前的迭代。为此,使用 %go test -c %mv fib.test fib.golden |
先前的Fib
功能具有斐波那契系列中第0和第1个数字的硬编码值。之后,代码以递归方式调用自身。我们将在今天晚些时候谈论递归的成本,但目前,假设它有成本,特别是因为我们的算法使用指数时间。
简单的解决方法就是从斐波纳契系列中硬编码另一个数字,将每个重复调用的深度减少一个。
func Fib(n int) int {
switch n {
case 0:
return 0
case 1:
return 1
case 2:
return 1
default:
return Fib(n-1) + Fib(n-2)
}
}
该文件还包括一个全面的测试Fib 。如果没有验证当前行为的测试,请不要尝试改进基准测试。 |
为了比较我们的新版本,我们编译了一个新的测试二进制文件并对它们进行基准测试并用于benchstat
比较输出。
% go test -c
% ./fib.golden -test.bench=. -test.count=10 > old.txt
% ./fib.test -test.bench=. -test.count=10 > new.txt
% benchstat old.txt new.txt
name old time/op new time/op delta
Fib20-8 44.3µs ± 6% 25.6µs ± 2% -42.31% (p=0.000 n=10+10)
比较基准测试时要检查三件事
-
新旧时代的方差±。1-2%是好的,3-5%是好的,大于5%并且您的一些样品将被认为是不可靠的。在比较一方具有高差异的基准时要小心,您可能没有看到改进。
-
p值。p值低于0.05是好的,大于0.05意味着基准可能没有统计学意义。
-
缺少样品。benchstat将报告它认为有效的旧样本和新样本的数量,有时您可能只会发现9个报告,即使您这样做了
-count=10
。10%或更低的拒绝率是可以的,高于10%可能表明您的设置不稳定,并且您可能比较的样本太少。
2.4。避免基准测试启动成本
有时您的基准测试每次运行设置成本为一次。b.ResetTimer()
将用于忽略设置中产生的时间。
func BenchmarkExpensive(b *testing.B) {
boringAndExpensiveSetup()
b.ResetTimer()
for n := 0; n < b.N; n++ {
// function under test
}
}
重置基准计时器 |
如果每次循环迭代都有一些昂贵的设置逻辑,请使用b.StopTimer()
和b.StartTimer()
暂停基准计时器。
func BenchmarkComplicated(b *testing.B) {
for n := 0; n < b.N; n++ {
b.StopTimer()
complicatedSetup()
b.StartTimer()
// function under test
}
}
暂停基准计时器 | |
恢复计时器 |
2.5。基准分配
分配计数和大小与基准时间密切相关。您可以告诉testing
框架记录被测代码所做的分配数量。
func BenchmarkRead(b *testing.B) {
b.ReportAllocs()
for n := 0; n < b.N; n++ {
// function under test
}
}
以下是使用bufio
软件包基准测试的示例。
% go test -run=^$ -bench=. bufio
goos: darwin
goarch: amd64
pkg: bufio
BenchmarkReaderCopyOptimal-8 20000000 103 ns/op
BenchmarkReaderCopyUnoptimal-8 10000000 159 ns/op
BenchmarkReaderCopyNoWriteTo-8 500000 3644 ns/op
BenchmarkReaderWriteToOptimal-8 5000000 344 ns/op
BenchmarkWriterCopyOptimal-8 20000000 98.6 ns/op
BenchmarkWriterCopyUnoptimal-8 10000000 131 ns/op
BenchmarkWriterCopyNoReadFrom-8 300000 3955 ns/op
BenchmarkReaderEmpty-8 2000000 789 ns/op 4224 B/op 3 allocs/op
BenchmarkWriterEmpty-8 2000000 683 ns/op 4096 B/op 1 allocs/op
BenchmarkWriterFlush-8 100000000 17.0 ns/op 0 B/op 0 allocs/op
您还可以使用该
|
2.6。注意编译器优化
这个例子来自问题14813。
const m1 = 0x5555555555555555
const m2 = 0x3333333333333333
const m4 = 0x0f0f0f0f0f0f0f0f
const h01 = 0x0101010101010101
func popcnt(x uint64) uint64 {
x -= (x >> 1) & m1
x = (x & m2) + ((x >> 2) & m2)
x = (x + (x >> 4)) & m4
return (x * h01) >> 56
}
func BenchmarkPopcnt(b *testing.B) {
for i := 0; i < b.N; i++ {
popcnt(uint64(i))
}
}
您认为此功能的基准测试速度有多快?我们来看看。
%go test -bench =。./examples/popcnt/ goos:达尔文 goarch:amd64 BenchmarkPopcnt-8 2000000000 0.30 ns / op 通过
0.3纳秒; 这基本上是一个时钟周期。即使假设CPU每个时钟周期内可能有一些飞行指令,这个数字似乎也不合理地低。发生了什么?
要了解发生了什么,我们必须看看benchmake下的功能popcnt
。 popcnt
是一个叶子函数 - 它不调用任何其他函数 - 所以编译器可以内联它。
因为函数是内联的,所以编译器现在可以看到它没有副作用。 popcnt
不会影响任何全局变量的状态。因此,呼叫被消除。这是编译器看到的:
func BenchmarkPopcnt(b *testing.B) {
for i := 0; i < b.N; i++ {
// optimised away
}
}
在我测试过的所有Go编译器版本中,仍然会生成循环。但是英特尔CPU非常擅长优化循环,尤其是空循环。
2.6.1。练习,看看大会
在我们继续之前,让我们看看组件以确认我们看到了什么
% go test -gcflags=-S
使用`gcflags =“ - l -S”禁用内联,这会如何影响程序集输出
优化是一件好事
要带走的是同样的优化,通过删除不必要的计算,使实际代码快速,与移除没有可观察到的副作用的基准相同的优化。 随着Go编译器的改进,这只会变得更加普遍。 |
2.6.2。修复基准
禁用内联以使基准工作是不现实的; 我们希望通过优化来构建我们的代码。
要修复此基准测试,我们必须确保编译器无法证明主体BenchmarkPopcnt
不会导致全局状态发生变化。
var Result uint64
func BenchmarkPopcnt(b *testing.B) {
var r uint64
for i := 0; i < b.N; i++ {
r = popcnt(uint64(i))
}
Result = r
}
这是确保编译器无法优化循环体的推荐方法。
首先,我们通过存储它来使用调用的结果。其次,因为一旦基准结束,就在本地范围内声明,结果永远不会被程序的另一部分看到,所以作为最终行为,我们将值赋给包公共变量。popcnt
r
r
BenchmarkPopcnt
r
r
Result
因为Result
是公共的,编译器无法证明导入这个的另一个包将无法看到Result
随时间变化的值,因此它无法优化导致其赋值的任何操作。
如果我们Result
直接分配会怎么样?这会影响基准时间吗?那么如果我们分配的结果popcnt
来_
?
在我们之前的Fib 基准测试中,如果我们这样做,我们没有采取这些预防措施? |
2.7。基准错误
该for
循环是基准的运行至关重要。
这是两个不正确的基准,你能解释一下它们有什么问题吗?
func BenchmarkFibWrong(b *testing.B) {
Fib(b.N)
}
func BenchmarkFibWrong2(b *testing.B) {
for n := 0; n < b.N; n++ {
Fib(n)
}
}
运行这些基准测试,您看到了什么?
2.8。分析基准
该testing
软件包内置支持生成CPU,内存和块配置文件。
-
-cpuprofile=$FILE
写一个CPU配置文件$FILE
。 -
-memprofile=$FILE
,写一个内存配置文件$FILE
,-memprofilerate=N
调整配置文件率1/N
。 -
-blockprofile=$FILE
,写一个块配置文件$FILE
。
使用这些标志中的任何一个也会保留二进制文件。
% go test -run=XXX -bench=. -cpuprofile=c.p bytes
% go tool pprof c.p
3.绩效衡量和分析
在上一节中,我们研究了各个函数的基准测试,当您提前知道瓶颈时,这些函数非常有用。但是,通常你会发现自己处于询问的位置
为什么这个程序运行这么长时间?
分析整个程序,这对于回答诸如此类的高级问题非常有用。在本节中,我们将使用Go内置的分析工具从内部调查程序的操作。
3.1。pprof
我们今天要讨论的第一个工具是pprof。pprof来自Google Perf Tools工具套件,并且自最早的公开发布以来已经集成到Go运行时中。
pprof
由两部分组成:
-
runtime/pprof
每个Go程序都内置了一个包 -
go tool pprof
用于调查配置文件。
3.2。配置文件的类型
pprof支持几种类型的分析,我们今天将讨论其中的三种:
-
CPU分析。
-
内存分析。
-
阻止(或阻止)分析。
-
Mutex争用分析。
3.2.1。CPU分析
CPU分析是最常见的配置文件类型,也是最明显的。
启用CPU分析后,运行时将每隔10ms自行中断并记录当前运行的goroutine的堆栈跟踪。
配置文件完成后,我们可以对其进行分析以确定最热门的代码路径。
函数在配置文件中出现的次数越多,代码路径占总运行时间的百分比就越多。
3.2.2。内存分析
内存分析在进行堆分配时记录堆栈跟踪。
堆栈分配被认为是免费的,并not_tracked在存储配置文件。
内存分析,如CPU分析是基于样本的,默认情况下每1000次分配中的内存分析样本1。这个比率可以改变。
由于内存分析是基于样本的,并且因为它跟踪分配不使用,因此使用内存分析来确定应用程序的总内存使用量是很困难的。
个人意见:我发现内存分析对查找内存泄漏没有用。有更好的方法可以确定应用程序使用的内存量。我们稍后将在演示文稿中讨论这些内容。
3.2.3。阻止分析
块分析对于Go来说是非常独特的。
块配置文件类似于CPU配置文件,但它记录了goroutine等待共享资源所花费的时间。
这对于确定应用程序中的并发瓶颈非常有用。
阻止分析可以显示大量goroutine何时可以取得进展但被阻止。阻止包括:
-
在无缓冲的频道上发送或接收。
-
发送到完整频道,从空频道接收。
-
尝试
Lock
一个sync.Mutex
被另一个goroutine中锁定。
块分析是一种非常专业的工具,在您认为已消除所有CPU和内存使用瓶颈之前,不应使用它。
3.3。一个时间档案
分析不是免费的。
分析对程序性能具有适度但可测量的影响 - 尤其是在增加内存配置文件采样率的情况下。
大多数工具不会阻止您一次启用多个配置文件。
不要一次启用多种配置文件。 如果您同时启用多个配置文件,他们将观察自己的互动并抛弃您的结果。 |
3.4。收集个人资料
Go运行时的分析界面存在于runtime/pprof
包中。runtime/pprof
是一种非常低级别的工具,由于历史原因,不同类型的配置文件的接口不一致。
正如我们在上一节中看到的那样,pprof概要分析内置于testing
包中,但有时在testing.B
基准测试的上下文中放置您想要分析的代码并且必须runtime/pprof
直接使用API是不方便或困难的。
几年前我写了一个[小包] [0],以便更容易分析现有的应用程序。
import "github.com/pkg/profile"
func main() {
defer profile.Start().Stop()
// ...
}
我们将在本节中使用配置文件包。当天晚些时候,我们将runtime/pprof
直接使用界面。
3.5。使用pprof分析配置文件
现在我们已经讨论了pprof可以测量的内容以及如何生成配置文件,让我们来谈谈如何使用pprof来分析配置文件。
分析由go pprof
子命令驱动
go tool pprof / path / to / your / profile
该工具提供了几种不同的分析数据表示; 文本,图形,甚至火焰图。
如果你已经使用了Go一段时间,你可能会被告知 |
3.5.1。进一步阅读
-
分析Go程序(Go Blog)
3.5.2。CPU分析(练习)
让我们写一个计算单词的程序:
package main
import (
"fmt"
"io"
"log"
"os"
"unicode"
"github.com/pkg/profile"
)
func readbyte(r io.Reader) (rune, error) {
var buf [1]byte
_, err := r.Read(buf[:])
return rune(buf[0]), err
}
func main() {
defer profile.Start().Stop()
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatalf("could not open file %q: %v", os.Args[1], err)
}
words := 0
inword := false
for {
r, err := readbyte(f)
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("could not read file %q: %v", os.Args[1], err)
}
if unicode.IsSpace(r) && inword {
words++
inword = false
}
inword = unicode.IsLetter(r)
}
fmt.Printf("%q: %d words\n", os.Args[1], words)
}
让我们来看看Herman Melville的经典Moby Dick中有多少单词(来自Project Gutenberg)
% go build && time ./words moby.txt
"moby.txt": 181275 words
real 0m2.110s
user 0m1.264s
sys 0m0.944s
让我们将它与unix进行比较 wc -w
% time wc -w moby.txt
215829 moby.txt
real 0m0.012s
user 0m0.009s
sys 0m0.002s
所以数字不一样。 wc
因为它认为一个单词与我的简单程序所做的不同,所以大约高出19%。这并不重要 - 两个程序都将整个文件作为输入,并在一次通过中计算从单词到非单词的转换次数。
让我们使用pprof调查这些程序为何具有不同的运行时间。
3.5.3。添加CPU分析
首先,编辑main.go
并启用分析
import (
"github.com/pkg/profile"
)
func main() {
defer profile.Start().Stop()
// ...
现在,当我们运行程序时,cpu.pprof
会创建一个文件。
% go run main.go moby.txt
2018/08/25 14:09:01 profile: cpu profiling enabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
"moby.txt": 181275 words
2018/08/25 14:09:03 profile: cpu profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
现在我们有了我们可以分析它的配置文件 go tool pprof
% go tool pprof /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
Type: cpu
Time: Aug 25, 2018 at 2:09pm (AEST)
Duration: 2.05s, Total samples = 1.36s (66.29%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 1.42s, 100% of 1.42s total
flat flat% sum% cum cum%
1.41s 99.30% 99.30% 1.41s 99.30% syscall.Syscall
0.01s 0.7% 100% 1.42s 100% main.readbyte
0 0% 100% 1.41s 99.30% internal/poll.(*FD).Read
0 0% 100% 1.42s 100% main.main
0 0% 100% 1.41s 99.30% os.(*File).Read
0 0% 100% 1.41s 99.30% os.(*File).read
0 0% 100% 1.42s 100% runtime.main
0 0% 100% 1.41s 99.30% syscall.Read
0 0% 100% 1.41s 99.30% syscall.read
该top
命令是您最常使用的命令。我们可以看到该计划花费99%的时间syscall.Syscall
,而且只占一小部分main.readbyte
。
我们还可以使用web
命令可视化此调用。这将从配置文件数据生成有向图。在幕后,它使用dot
Graphviz 的命令。
但是,在Go 1.10(可能是1.11)中,Go附带了本机支持http服务器的pprof版本
% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile239941020/cpu.pprof
将打开一个Web浏览器;
-
图形模式
-
火焰图模式
在图表中,消耗最多 CPU时间的框是最大的 - 我们看到sys call.Syscall
在程序中花费的总时间的99.3%。导致syscall.Syscall
代表直接调用者的方框字符串- 如果多个代码路径聚合在同一个函数上,则可以有多个。箭头的大小表示在一个盒子的子节点上花费了多少时间,我们看到它们从main.readbyte
图表的这个臂开始占据了1.41秒的近0。
问:有谁能猜到为什么我们的版本比这么慢wc
?
3.5.4。改进我们的版本
我们的程序很慢的原因并不是因为Go的syscall.Syscall
速度很慢。这是因为系统调用通常是昂贵的操作(并且随着发现更多的Spectre系列漏洞而变得越来越昂贵)。
每次调用都会readbyte
产生一个缓冲区大小为1的syscall.Read。因此,我们程序执行的系统调用数等于输入的大小。我们可以看到,在pprof图中,读取输入主导其他所有内容。
func main() {
defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
// defer profile.Start(profile.MemProfile).Stop()
f, err := os.Open(os.Args[1])
if err != nil {
log.Fatalf("could not open file %q: %v", os.Args[1], err)
}
b := bufio.NewReader(f)
words := 0
inword := false
for {
r, err := readbyte(b)
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("could not read file %q: %v", os.Args[1], err)
}
if unicode.IsSpace(r) && inword {
words++
inword = false
}
inword = unicode.IsLetter(r)
}
fmt.Printf("%q: %d words\n", os.Args[1], words)
}
通过bufio.Reader
在输入文件和readbyte
将之间插入一个
比较修订后的计划的时间wc
。它有多近?获取个人资料,看看剩下的是什么。
3.5.5。内存分析
新的words
配置文件表明在readbyte
函数内部分配了一些东西。我们可以用pprof来调查。
defer profile.Start(profile.MemProfile).Stop()
然后像往常一样运行程序
% go run main2.go moby.txt
2018/08/25 14:41:15 profile: memory profiling enabled (rate 4096), /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile312088211/mem.pprof
"moby.txt": 181275 words
2018/08/25 14:41:15 profile: memory profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile312088211/mem.pprof
因为我们怀疑分配来自readbyte
- 这并不复杂,readbyte是三行长:
使用pprof确定分配的来源。
func readbyte(r io.Reader) (rune, error) {
var buf [1]byte
_, err := r.Read(buf[:])
return rune(buf[0]), err
}
分配在这里 |
我们将在下一节中详细讨论为什么会发生这种情况,但目前我们看到的是每次调用readbyte都会分配一个新的一个字节长的数组,并且该数组正在堆上分配。
有什么方法可以避免这种情况?尝试使用它们并使用CPU和内存分析来证明它。
Alloc对象与使用对象
内存配置文件有两种,以其go tool pprof
标志命名
-
-alloc_objects
报告每次分配的对象。 -
-inuse_objects
如果在配置文件末尾可以访问,则报告已进行分配的对象。
为了证明这一点,这是一个人为的程序,它将以受控的方式分配一堆内存。
const count = 100000
var y []byte
func main() {
defer profile.Start(profile.MemProfile, profile.MemProfileRate(1)).Stop()
y = allocate()
runtime.GC()
}
// allocate allocates count byte slices and returns the first slice allocated.
func allocate() []byte {
var x [][]byte
for i := 0; i < count; i++ {
x = append(x, makeByteSlice())
}
return x[0]
}
// makeByteSlice returns a byte slice of a random length in the range [0, 16384).
func makeByteSlice() []byte {
return make([]byte, rand.Intn(2^14))
}
该程序是profile
包的注释,我们将内存配置文件速率设置为1
- 即,记录每个分配的堆栈跟踪。这会让节目变得很慢,但是你会在一分钟内看到原因。
% go run main.go
2018/08/25 15:22:05 profile: memory profiling enabled (rate 1), /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile730812803/mem.pprof
2018/08/25 15:22:05 profile: memory profiling disabled, /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile730812803/mem.pprof
让我们看一下分配对象的图形,这是默认设置,并显示在配置文件期间导致分配每个对象的调用图。
% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile891268605/mem.pprof

毫不奇怪,超过99%的拨款都在内部makeByteSlice
。现在让我们使用相同的配置文件-inuse_objects
% go tool pprof -http=:8080 /var/folders/by/3gf34_z95zg05cyj744_vhx40000gn/T/profile891268605/mem.pprof

我们看到的不是在配置文件期间分配的对象,而是在获取配置文件时仍在使用的对象- 这忽略了垃圾收集器已回收的对象的堆栈跟踪。
3.5.6。阻塞分析
我们将看到的最后一个配置文件类型是块分析。我们将使用包中的ClientServer
基准net/http
% go test -run=XXX -bench=ClientServer$ -blockprofile=/tmp/block.p net/http
% go tool pprof -http=:8080 /tmp/block.p

3.5.8。Framepointers
Go 1.7已经发布,并且与amd64的新编译器一起,编译器现在默认启用帧指针。
帧指针是一个始终指向当前堆栈帧顶部的寄存器。
Framepointers启用类似工具gdb(1)
,并perf(1)
了解Go调用堆栈。
我们不会在本次研讨会中介绍这些工具,但您可以阅读并观看我用七种不同方式介绍Go程序的演示文稿。
-
描述Go程序的七种方法(幻灯片)
-
描述Go计划的七种方式(视频,30分钟)
-
描述Go计划的七种方式(网络直播,60分钟)
3.5.9。运行
-
从您熟悉的一段代码生成配置文件。如果您没有代码示例,请尝试进行性能分析
godoc
。% go get golang.org/x/tools/cmd/godoc % cd $GOPATH/src/golang.org/x/tools/cmd/godoc % vim main.go
-
如果你要在一台机器上生成一个配置文件并在另一台机器上检查它,你会怎么做?
4.编译器优化
本节介绍Go编译器执行的一些优化。
例如;
-
逃生分析
-
内联
-
死代码消除
都在编译器的前端处理,而代码仍然是AST形式; 然后将代码传递给SSA编译器以进行进一步优化。
4.1。Go编译器的历史
Go编译器在2007年左右开始作为Plan9编译器工具链的一个分支。当时的编译器与Aho和Ullman的Dragon Book非常相似。
4.2。逃逸分析
我们要讨论的第一个优化是逃逸分析(一种确定指针动态范围的方法)。
为了说明逃逸分析确实回忆起Go规范没有提到堆或堆栈。它只提到语言在引言中是垃圾收集,并没有提供如何实现这一点的提示。
Go规范的兼容Go实现可以在堆上存储每个分配。这会给垃圾收集器带来很大的压力,但这绝不是错误的 - 几年来,gccgo对逃逸分析的支持非常有限,因此可以有效地被认为是在这种模式下运行。
但是,goroutine的堆栈作为存储局部变量的廉价位置存在; 没有必要在堆栈上垃圾收集。因此,在安全的情况下,放置在堆栈上的分配将更有效。
在某些语言中,例如C和C ++,在堆栈或堆上分配的选择是程序员堆的分配的手动练习,malloc
并且free
堆栈分配是通过alloca
。使用这些机制的错误是内存损坏错误的常见原因。
在Go中,如果值超出函数调用的生命周期,编译器会自动将值移动到堆中。据说该值会 逃逸到堆中。
type Foo struct {
a, b, c, d int
}
func NewFoo() *Foo {
return &Foo{a: 3, b: 1, c: 4, d: 7}
}
在此示例中,已Foo
分配的内容NewFoo
将被移动到堆,因此其内容在NewFoo
返回后仍保持有效。
这一直存在于Go的早期。它不是一个优化的自动正确性功能。在Go中无法意外地返回堆栈分配变量的地址。
但编译器也可以做相反的事情; 它可以找到假定在堆上分配的东西,并将它们移动到堆栈。
我们来看一个例子吧
func Sum() int {
const count = 100
numbers := make([]int, count)
for i := range numbers {
numbers[i] = i + 1
}
var sum int
for _, i := range numbers {
sum += i
}
return sum
}
func main() {
answer := Sum()
fmt.Println(answer)
}
Sum
将`int`s添加到1到100之间并返回结果。
因为numbers
切片仅在内部引用Sum
,所以编译器将安排在堆栈上存储该切片的100个整数,而不是堆。不需要垃圾回收numbers
,Sum
返回时会自动释放。
4.2.1。证明给我看!
要打印编译器转义分析决策,请使用该-m
标志。
% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:22:13: inlining call to fmt.Println
examples/esc/sum.go:8:17: Sum make([]int, count) does not escape
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13: main []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
第8行显示编译器已正确推断出结果make([]int, 100)
不会转移到堆。之所以没有
第22行报告answer
逃逸到堆的原因fmt.Println
是可变函数。到可变参数函数的参数被盒装入一个切片,在这种情况下[]interface{}
,使answer
被放入一个接口值,因为它是由呼叫引用fmt.Println
。由于围棋1.6的垃圾收集器,需要所有通过接口被传为指针,什么编译器看到的是价值约:
var answer = Sum()
fmt.Println([]interface{&answer}...)
我们可以使用-gcflags="-m -m"
旗帜确认这一点。哪个回报
% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:22
examples/esc/sum.go:22:13: inlining call to fmt.Println func(...interface {}) (int, error) { return fmt.Fprintln(io.Writer(os.Stdout), fmt.a...) }
examples/esc/sum.go:22:13: answer escapes to heap
examples/esc/sum.go:22:13: from ~arg0 (assign-pair) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: io.Writer(os.Stdout) escapes to heap
examples/esc/sum.go:22:13: from io.Writer(os.Stdout) (passed to call[argument escapes]) at examples/esc/sum.go:22:13
examples/esc/sum.go:22:13: main []interface {} literal does not escape
总之,不要担心第22行,它对这个讨论并不重要。
4.2.2。演习
-
这种优化是否适用于所有值
count
? -
如果
count
是变量而不是常数,这种优化是否成立? -
如果
count
是参数,这个优化是否成立Sum
?
4.2.3。逃逸分析(续)
这个例子有点人为。它不是真正的代码,只是一个例子。
type Point struct{ X, Y int }
const Width = 640
const Height = 480
func Center(p *Point) {
p.X = Width / 2
p.Y = Height / 2
}
func NewPoint() {
p := new(Point)
Center(p)
fmt.Println(p.X, p.Y)
}
NewPoint
创造一个新的*Point
价值p
。我们传递p
给将Center
点移动到屏幕中心位置的功能。最后,我们打印的数值p.X
和p.Y
。
% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:11:6: can inline Center
examples/esc/center.go:18:8: inlining call to Center
examples/esc/center.go:19:13: inlining call to fmt.Println
examples/esc/center.go:11:13: Center p does not escape
examples/esc/center.go:19:15: p.X escapes to heap
examples/esc/center.go:19:20: p.Y escapes to heap
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap
examples/esc/center.go:17:10: NewPoint new(Point) does not escape
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape
<autogenerated>:1: os.(*File).close .this does not escape
即使p
分配了该new
函数,它也不会存储在堆上,因为没有引用会p
转义该Center
函数。
问题:第19行怎么样,如果p
没有逃脱,什么逃逸到堆?
写一个基准来提供Sum
不分配。
4.3。内联
在Go函数中,调用具有固定的开销; 堆栈和抢占检查。
其中一些可以通过硬件分支预测器得到改善,但在功能大小和时钟周期方面仍然是成本。
内联是避免这些成本的经典优化。
直到Go 1.11内联仅适用于叶子函数,一个不调用另一个函数的函数。对此的理由是:
-
如果你的功能做了很多工作,那么前导码开销可以忽略不计。这就是为什么函数超过一定的大小(当前有一些指令计数,加上一些阻止所有内联在一起的操作(例如,在Go 1.7之前切换)
-
另一方面,小功能为相对少量的有用工作支付固定的开销。这些是内联目标的功能,因为它们受益最多。
另一个原因是重型内联使得堆栈跟踪更难以遵循。
4.3.1。内联(示例)
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
我们再次使用该-gcflags=-m
标志来查看编译器优化决策。
% go build -gcflags=-m examples/inl/max.go
# command-line-arguments
examples/inl/max.go:4:6: can inline Max
examples/inl/max.go:11:6: can inline F
examples/inl/max.go:13:8: inlining call to Max
examples/inl/max.go:20:6: can inline main
examples/inl/max.go:21:3: inlining call to F
examples/inl/max.go:21:3: inlining call to Max
编译器打印了两行。
-
第3行的第一个,声明
Max
,告诉我们它可以内联。 -
第二个是报告
Max
第12行的内容已被内联到呼叫者。
不使用//go:noinline
注释,重写Max
使得它仍然返回正确的答案,但不再被编译器认为是可内联的。
4.3.2。内联是什么样的?
编译max.go
并查看优化版本的内容F()
。
% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=2 args=0x0 locals=0x0
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) TEXT "".F(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11) FUNCDATA $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13) PCDATA $2, $0
这是F
曾经Max
被内联的主体- 这个功能没有发生任何事情。我知道屏幕上有很多文字什么都没有,但是接受我的话,唯一发生的事情是RET
。实际上F
成了:
func F() {
return
}
什么是FUNCDATA和PCDATA?
输出 |
4.3.3。讨论
为什么我声明a
,并b
在F()
为常数?
尝试输出如果a
和b
被声明为变量会发生什么?如果a
和作为参数b
传递会发生什么F()
?
-gcflags=-S 不会阻止在工作目录中构建最终二进制文件。如果发现后续运行go build … 没有产生输出,请删除./max 工作目录中的二进制文件。 |
4.3.4。调整内联级别
使用标志执行调整内联级别-gcflags=-l
。有些令人困惑的传递单个-l
将禁用内联,两个或更多将启用内联更积极的设置。
-
-gcflags=-l
,内联禁用。 -
没事,定期内联。
-
-gcflags='-l -l'
内联级别2,更具侵略性,可能更快,可能会制作更大的二进制文件。 -
-gcflags='-l -l -l'
内联级别3,再次更具侵略性,二进制文件肯定更大,可能更快,但也可能是错误的。 -
-gcflags=-l=4
Go 1.11中的四个“-l`s”将启用实验性中间堆栈内联优化。
4.3.5。中间堆栈内联
由于Go 1.12所谓的中间堆栈内联已经启用(之前在Go 1.11中预览了-gcflags='-l -l -l -l'
旗帜)。
我们可以在前面的示例中看到中间堆栈内联的示例。在Go 1.11和更早版本中F
,它不会是一个叶子函数 - 它会调用max
。然而,由于内联改进F
现已内联到其调用者中。这有两个原因; 。当max
内联时F
,不F
包含其他函数调用,因此它成为潜在的叶函数,假设其复杂性预算未被超过。。因为F
简单的函数内联和死代码消除已经消除了它的大部分复杂性预算 - 无论调用如何,它都可以用于中间堆栈内联max
。
中间堆栈内联可用于内联函数的快速路径,从而消除快速路径中的函数调用开销。 这个最近登陆的CL用于Go 1.13,显示了这种技术适用于 |
4.4。死代码消除
为什么重要的是a
和b
是常数?
要了解发生了什么事让我们来看看编译器看到一旦其内联什么Max
成F
。我们无法轻易地从编译器中获得这一点,但是它可以直接手动完成。
之前:
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func F() {
const a, b = 100, 20
if Max(a, b) == b {
panic(b)
}
}
后:
func F() {
const a, b = 100, 20
var result int
if a > b {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
因为a
并且b
是常量,编译器可以在编译时证明分支永远不会为假; 100
永远大于20
。所以编译器可以进一步优化F
到
func F() {
const a, b = 100, 20
var result int
if true {
result = a
} else {
result = b
}
if result == b {
panic(b)
}
}
既然知道了分支的结果,那么内容result
也是已知的。这是呼叫分支消除。
func F() {
const a, b = 100, 20
const result = a
if result == b {
panic(b)
}
}
现在分支被消除,我们知道result
总是等于a
,因为a
是一个常数,我们知道这result
是一个常数。编译器将此证明应用于第二个分支
func F() {
const a, b = 100, 20
const result = a
if false {
panic(b)
}
}
并且再次使用分支消除,最终形式F
减少到。
func F() {
const a, b = 100, 20
const result = a
}
最后只是
func F() {
}
4.4.1。死代码消除(续)
分支消除是称为死代码消除的一类优化之一。实际上,使用静态证明来表明一段代码永远不可达,通常称为死,因此无需在最终二进制文件中进行编译,优化或发出。
我们看到了死代码消除如何与内联一起工作,以减少通过删除被证明无法访问的循环和分支生成的代码量。
您可以利用此功能来实现昂贵的调试,并将其隐藏起来
const debug = false
结合构建标记,这可能非常有用。
4.5。编译器标志练习
编译器标志提供:
go build -gcflags=$FLAGS
调查以下编译器函数的操作:
-
-S
打印正在编译的包的(Go flavor)程序集。 -
-l
控制内衬的行为;-l
禁用内联,-l -l
增加它(更多-l
会增加编译器对内联代码的兴趣)。试验编译时间,程序大小和运行时间的差异。 -
-m
控制优化决策的打印,如内联,逃逸分析。-m
-m`打印出有关编译器思考内容的更多细节。 -
-l -N
禁用所有优化。
如果发现后续运行go build … 没有产生输出,请删除./max 工作目录中的二进制文件。 |
4.6。界限检查消除
Go是一种边界检查语言。这意味着检查数组和切片下标操作以确保它们在相应类型的范围内。
对于数组,这可以在编译时完成。对于切片,这必须在运行时完成。
var v = make([]int, 9)
var A, B, C, D, E, F, G, H, I int
func BenchmarkBoundsCheckInOrder(b *testing.B) {
for n := 0; n < b.N; n++ {
A = v[0]
B = v[1]
C = v[2]
D = v[3]
E = v[4]
F = v[5]
G = v[6]
H = v[7]
I = v[8]
}
}
使用-gcflags=-S
拆卸BenchmarkBoundsCheckInOrder
。每个循环执行多少个边界检查操作?
func BenchmarkBoundsCheckOutOfOrder(b *testing.B) {
for n := 0; n < b.N; n++ {
I = v[8]
A = v[0]
B = v[1]
C = v[2]
D = v[3]
E = v[4]
F = v[5]
G = v[6]
H = v[7]
}
}
重新排列我们分配A
直通的顺序I
会影响装配。拆卸BenchmarkBoundsCheckOutOfOrder
并找出答案。
4.6.1。演习
-
重新排列下标操作的顺序是否会影响函数的大小?它会影响功能的速度吗?
-
如果
v
移动到Benchmark
函数内部会发生什么? -
如果
v
声明为数组会发生什么var v [9]int
?
5.执行追踪
执行追踪器是由Dmitry Vyukov为Go 1.5 开发的,并且仍然记录在案,并且未充分利用了好几年。
与基于样本的分析不同,执行跟踪器集成到Go运行时,因此它只知道Go程序在特定时间点正在做什么,但为什么。
5.1。什么是执行跟踪器,我们为什么需要它?
我认为最容易解释执行跟踪器的作用,以及为什么通过查看pprof go tool pprof
表现不佳的代码片段来说这很重要。
该examples/mandelbrot
目录包含一个简单的mandelbrot生成器。此代码源自Francesc Campoy的mandelbrot包。
cd examples/mandelbrot
go build && ./mandelbrot
如果我们构建它,然后运行它,它会生成这样的东西

5.1.1。多久时间?
那么,该程序生成1024 x 1024像素图像需要多长时间?
我知道如何做到这一点的最简单方法是使用类似的东西time(1)
。
% time ./mandelbrot
real 0m1.654s
user 0m1.630s
sys 0m0.015s
不要使用time go run mandebrot.go 或者你需要花费多长时间来编译程序以及运行程序。 |
5.1.2。该计划在做什么?
因此,在这个例子中,程序用1.6秒生成mandelbrot并写入png。
这样好吗?我们可以加快速度吗?
回答这个问题的一种方法是使用Go的内置pprof支持来分析程序。
我们试试吧。
5.3。使用runtime / pprof生成配置文件
为了向您展示没有魔力,让我们修改程序以编写CPU配置文件os.Stdout
。
import "runtime/pprof"
func main() {
pprof.StartCPUProfile(os.Stdout)
defer pprof.StopCPUProfile()
通过将此代码添加到main
函数的顶部,此程序将编写配置文件os.Stdout
。
cd examples/mandelbrot-runtime-pprof
go run mandelbrot.go > cpu.pprof
我们可以go run 在这种情况下使用,因为cpu配置文件只包含执行mandelbrot.go ,而不包括其编译。 |
5.3.1。使用github.com/pkg/profile生成配置文件
上一张幻灯片显示了生成配置文件的超级便宜方式,但它有一些问题。
-
如果您忘记将输出重定向到文件,那么您将爆炸该终端会话。😞(提示:
reset(1)
是你的朋友) -
os.Stdout
例如,如果你写任何其他内容,fmt.Println
你将破坏跟踪。
建议使用的方法runtime/pprof
是将跟踪写入文件。但是,你必须确保跟踪停止,文件在你的程序停止之前关闭,包括是否有人`^ C'。
所以,几年前我写了一个包来照顾它。
import "github.com/pkg/profile"
func main() {
defer profile.Start(profile.CPUProfile, profile.ProfilePath(".")).Stop()
如果我们运行此版本,我们会将配置文件写入当前工作目录
% go run mandelbrot.go
2017/09/17 12:22:06 profile: cpu profiling enabled, cpu.pprof
2017/09/17 12:22:08 profile: cpu profiling disabled, cpu.pprof
使用pkg/profile 不是强制性的,但它会收集很多关于收集和记录痕迹的样板,因此我们将在本次研讨会的其余部分使用它。 |
5.3.2。分析个人资料
现在我们有了一个配置文件,我们可以go tool pprof
用来分析它。
% go tool pprof -http=:8080 cpu.pprof
在这次运行中,我们看到程序运行了1.81秒(分析增加了一小部分开销)。我们还可以看到pprof仅捕获数据1.53秒,因为pprof是基于样本的,依赖于操作系统的SIGPROF
计时器。
从Go 1.9开始,pprof 跟踪包含分析跟踪所需的所有信息。您不再需要也具有生成跟踪的匹配二进制文件。🎉 |
我们可以使用top
pprof函数对跟踪记录的函数进行排序
% go tool pprof cpu.pprof
Type: cpu
Time: Mar 24, 2019 at 5:18pm (CET)
Duration: 2.16s, Total samples = 1.91s (88.51%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 1.90s, 99.48% of 1.91s total
Showing top 10 nodes out of 35
flat flat% sum% cum cum%
0.82s 42.93% 42.93% 1.63s 85.34% main.fillPixel
0.81s 42.41% 85.34% 0.81s 42.41% main.paint
0.11s 5.76% 91.10% 0.12s 6.28% runtime.mallocgc
0.04s 2.09% 93.19% 0.04s 2.09% runtime.memmove
0.04s 2.09% 95.29% 0.04s 2.09% runtime.nanotime
0.03s 1.57% 96.86% 0.03s 1.57% runtime.pthread_cond_signal
0.02s 1.05% 97.91% 0.04s 2.09% compress/flate.(*compressor).deflate
0.01s 0.52% 98.43% 0.01s 0.52% compress/flate.(*compressor).findMatch
0.01s 0.52% 98.95% 0.01s 0.52% compress/flate.hash4
0.01s 0.52% 99.48% 0.01s 0.52% image/png.filter
main.fillPixel
当pprof捕获堆栈时,我们看到该函数在CPU上最多。
main.paint
在堆栈上找到并不奇怪,这就是程序的作用; 它描绘了像素。但是paint
花了这么多时间的原因是什么?我们可以用累积标志来检查top
。
(pprof) top --cum
Showing nodes accounting for 1630ms, 85.34% of 1910ms total
Showing top 10 nodes out of 35
flat flat% sum% cum cum%
0 0% 0% 1840ms 96.34% main.main
0 0% 0% 1840ms 96.34% runtime.main
820ms 42.93% 42.93% 1630ms 85.34% main.fillPixel
0 0% 42.93% 1630ms 85.34% main.seqFillImg
810ms 42.41% 85.34% 810ms 42.41% main.paint
0 0% 85.34% 210ms 10.99% image/png.(*Encoder).Encode
0 0% 85.34% 210ms 10.99% image/png.Encode
0 0% 85.34% 160ms 8.38% main.(*img).At
0 0% 85.34% 160ms 8.38% runtime.convT2Inoptr
0 0% 85.34% 150ms 7.85% image/png.(*encoder).writeIDATs
这有点暗示main.fillPixed
实际上正在完成大部分工作。
5.4。跟踪与分析
希望这个例子显示了分析的局限性。剖析告诉我们剖面仪看到了什么; fillPixel
正在做所有的工作。看起来没有那么多可以做的事情。
所以现在是引入执行跟踪器的好时机,它给出了同一程序的不同视图。
5.4.1。使用执行跟踪器
使用跟踪器就像要求一样简单profile.TraceProfile
,没有其他任何改变。
import "github.com/pkg/profile"
func main() {
defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
当我们运行程序时,我们trace.out
在当前工作目录中获取一个文件。
% go build mandelbrot.go
% % time ./mandelbrot
2017/09/17 13:19:10 profile: trace enabled, trace.out
2017/09/17 13:19:12 profile: trace disabled, trace.out
real 0m1.740s
user 0m1.707s
sys 0m0.020s
就像pprof一样,go
命令中有一个工具来分析跟踪。
% go tool trace trace.out
2017/09/17 12:41:39 Parsing trace...
2017/09/17 12:41:40 Serializing trace...
2017/09/17 12:41:40 Splitting trace...
2017/09/17 12:41:40 Opening browser. Trace viewer s listening on http://127.0.0.1:57842
这个工具有点不同go tool pprof
。执行跟踪器正在重复使用Chrome中内置的大量配置文件可视化基础架构,因此go tool trace
充当服务器将原始执行跟踪转换为Chome可以本机显示的数据。
5.4.2。分析痕迹
我们可以从跟踪中看到程序只使用一个cpu。
func seqFillImg(m *img) {
for i, row := range m.m {
for j := range row {
fillPixel(m, i, j)
}
}
}
这并不奇怪,默认情况下按顺序mandelbrot.go
调用fillPixel
每一行中的每个像素。
绘制图像后,请参阅执行开关以写入.png
文件。这会在堆上生成垃圾,因此跟踪在此时发生变化,我们可以看到垃圾收集堆的经典锯齿模式。
跟踪配置文件提供低至微秒级别的定时分辨率。这是您通过外部分析无法获得的。
去工具痕迹
在我们继续之前,我们应该谈谈跟踪工具的使用。
|
5.5。使用多个CPU
我们从前面的跟踪中看到,程序正在按顺序运行,而不是利用此计算机上的其他CPU。
Mandelbrot一代被称为embarassingly_parallel。每个像素都是独立的,它们都可以并行计算。那么,让我们试试吧。
% go build mandelbrot.go
% time ./mandelbrot -mode px
2017/09/17 13:19:48 profile: trace enabled, trace.out
2017/09/17 13:19:50 profile: trace disabled, trace.out
real 0m1.764s
user 0m4.031s
sys 0m0.865s
所以运行时基本相同。有更多的用户时间,这是有道理的,我们使用所有的CPU,但实际(挂钟)时间大致相同。
让我们来看看。
如您所见,此跟踪生成了更多数据。
-
看起来很多工作正在完成,但如果你放大,就会有差距。这被认为是调度程序。
-
虽然我们使用所有四个核心,因为每个核心
fillPixel
的工作量相对较小,但我们在调度开销方面花费了大量时间。
5.6。批量工作
每个像素使用一个goroutine太精细了。没有足够的工作来证明goroutine的成本。
相反,让我们尝试每个goroutine处理一行。
% go build mandelbrot.go
% time ./mandelbrot -mode row
2017/09/17 13:41:55 profile: trace enabled, trace.out
2017/09/17 13:41:55 profile: trace disabled, trace.out
real 0m0.764s
user 0m1.907s
sys 0m0.025s
这看起来是一个很好的改进,我们差不多将程序的运行时间减半。我们来看看这条痕迹。
正如您所看到的,跟踪现在更小,更易于使用。我们可以看到整个轨迹,这是一个很好的奖励。
-
在程序开始时,我们看到goroutines的数量增加到大约1,000。这是我们在前面的描述中看到的1 << 20的改进。
-
放大我们看到
onePerRowFillImg
运行时间更长,并且由于goroutine 生产工作提前完成,调度程序有效地通过剩余的可运行的goroutine。
5.7。使用工人
mandelbrot.go
支持另一种模式,让我们尝试一下。
% go build mandelbrot.go
% time ./mandelbrot -mode workers
2017/09/17 13:49:46 profile: trace enabled, trace.out
2017/09/17 13:49:50 profile: trace disabled, trace.out
real 0m4.207s
user 0m4.459s
sys 0m1.284s
所以,运行时比以前任何时候都要糟糕得多。让我们看看跟踪,看看我们是否能弄清楚发生了什么。
查看跟踪,您可以看到,只有一个工作进程,生产者和消费者倾向于交替,因为只有一个工作者和一个消费者。让我们增加工人数量
% go build mandelbrot.go
% time ./mandelbrot -mode workers -workers 4
2017/09/17 13:52:51 profile: trace enabled, trace.out
2017/09/17 13:52:57 profile: trace disabled, trace.out
real 0m5.528s
user 0m7.307s
sys 0m4.311s
这让事情变得更糟!更实时,更多的CPU时间。让我们看看跟踪,看看发生了什么。
那条痕迹是一团糟。有更多的工人可用,但似乎花了他们所有的时间来争取工作。
这是因为通道是无缓冲的。在有人准备好接收之前,无法发送无缓冲的频道。
-
生产者在工人准备接收工作之前不能发送工作。
-
工作人员在有人准备发送之前无法接收工作,因此他们在等待时互相竞争。
-
发件人没有特权,它不能优先于已经运行的工作人员。
我们在这里看到的是无缓冲通道引入的大量延迟。调度程序内部有很多停止和启动,并且在等待工作时可能会锁定和互斥,这就是我们看到sys
时间更长的原因。
5.8。使用缓冲的通道
import "github.com/pkg/profile"
func main() {
defer profile.Start(profile.TraceProfile, profile.ProfilePath(".")).Stop()
% go build mandelbrot.go
% time ./mandelbrot -mode workers -workers 4
2017/09/17 14:23:56 profile: trace enabled, trace.out
2017/09/17 14:23:57 profile: trace disabled, trace.out
real 0m0.905s
user 0m2.150s
sys 0m0.121s
这与上面的每行模式非常接近。
使用缓冲通道,跟踪向我们显示:
-
生产者不必等待工人到达,它可以快速填满渠道。
-
工人可以快速从通道中取出下一个项目,而无需等待工作生成。
使用这种方法,我们获得了几乎相同的速度,使用一个通道来切换每个像素的工作,而不是之前在每行goroutine上调度。
修改nWorkersFillImg
为每行工作。计算结果并分析跟踪。
5.9。Mandelbrot微服务
它是2019年,生成Mandelbrots毫无意义,除非你可以在互联网上提供它们作为无服务器的微服务。因此,我向你呈现Mandelweb
% go run examples/mandelweb/mandelweb.go
2017/09/17 15:29:21 listening on http://127.0.0.1:8080/
5.9.1。跟踪正在运行的应用
在前面的示例中,我们在整个程序中运行了跟踪。
如您所见,即使在很短的时间内,跟踪也可能非常大,因此不断收集跟踪数据会产生太多数据。此外,跟踪可能会对程序的速度产生影响,尤其是在有大量活动的情况下。
我们想要的是一种从正在运行的程序中收集短跟踪的方法。
很有可能,net/http/pprof
包装就是这样的设施。
5.9.2。通过http收集痕迹
希望每个人都知道net/http/pprof
包装。
import _ "net/http/pprof"
导入时,net/http/pprof
将使用注册跟踪和分析路由http.DefaultServeMux
。从Go 1.5开始,这包括跟踪分析器。
net/http/pprof 注册http.DefaultServeMux 。如果您ServeMux 隐式或明确地使用它,您可能会无意中将pprof端点暴露给Internet。这可能导致源代码泄露。你可能不想这样做。 |
我们可以用curl
(或wget
)从mandelweb获取五秒钟的痕迹
% curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5
5.9.3。生成一些负载
前面的示例很有趣,但根据定义,空闲的Web服务器没有性能问题。我们需要产生一些负载。为此,我正在使用hey
JBD。
% go get -u github.com/rakyll/hey
让我们从每秒一个请求开始。
% hey -c 1 -n 1000 -q 1 http://127.0.0.1:8080/mandelbrot
随着运行,在另一个窗口收集跟踪
% curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 66169 0 66169 0 0 13233 0 --:--:-- 0:00:05 --:--:-- 17390
% go tool trace trace.out
2017/09/17 16:09:30 Parsing trace...
2017/09/17 16:09:30 Serializing trace...
2017/09/17 16:09:30 Splitting trace...
2017/09/17 16:09:30 Opening browser.
Trace viewer is listening on http://127.0.0.1:60301
5.9.4。模拟过载
让我们将速率提高到每秒5个请求。
% hey -c 5 -n 1000 -q 5 http://127.0.0.1:8080/mandelbrot
随着运行,在另一个窗口收集跟踪
%curl -o trace.out http://127.0.0.1:8080/debug/pprof/trace?seconds=5 %总收到百分比%Xferd平均速度时间时间当前时间 Dload上载总左转速度 100 66169 0 66169 0 0 13233 0 - : - : - 0:00:05 - : - : - 17390 %go工具跟踪trace.out 2017/09/17 16:09:30解析痕迹...... 2017/09/17 16:09:30序列化痕迹...... 2017/09/17 16:09:30分裂痕迹...... 2017/09/17 16:09:30打开浏览器。跟踪查看器正在侦听http://127.0.0.1:60301
5.9.6。更多资源
-
Rhys Hiltner,Go的执行追踪者(dotGo 2016)
-
Rhys Hiltner,“go tool trace”简介(GopherCon 2017)
-
Dave Cheney,介绍Go项目的七种方式(GolangUK 2016)
-
Dave Cheney,高绩效围棋研讨会 ]
-
Ivan Daniluk,在Go中可视化并发(GopherCon 2016)
-
Kavya Joshi,理解频道(GopherCon 2017)
-
Francesc Campoy,使用Go执行跟踪器
6.内存和垃圾收集器
Go是一种垃圾收集语言。这是一个设计原则,它不会改变。
作为垃圾收集语言,Go程序的性能通常取决于它们与垃圾收集器的交互。
在您选择的算法旁边,内存消耗是决定应用程序性能和可伸缩性的最重要因素。
本节讨论垃圾收集器的操作,如何测量程序的内存使用情况以及在垃圾收集器性能是瓶颈时降低内存使用率的策略。
6.1。垃圾收集器世界观
任何垃圾收集器的目的都是为了表明程序可以使用无限量的内存。
您可能不同意这种说法,但这是垃圾收集器设计者如何工作的基本假设。
停止世界,标记扫描GC在总运行时间方面是最有效的; 适用于批处理,模拟等。但是,随着时间的推移,Go GC已经从纯粹的世界收集器转变为并发的非压缩收集器。这是因为Go GC专为低延迟服务器和交互式应用程序而设计。
Go GC的设计倾向于lower_latency而不是maximum_throughput ; 它将一些分配成本转移到mutator以降低以后的清理成本。
6.2。垃圾收集器设计
多年来,Go GC的设计发生了变化
-
去1.0,严重依赖tcmalloc停止世界标记扫描收集器。
-
去1.3,完全精确的收集器,不会将堆上的大数字误认为指针,从而泄漏内存。
-
Go 1.5,新的GC设计,专注于延迟超过吞吐量。
-
进行1.6,GC改进,处理更大的堆,延迟更低。
-
去1.7,小GC改进,主要是重构。
-
进入1.8,进一步减少STW时间,现在降至100微秒范围。
-
转到1.10+,远离纯粹的cooprerative goroutine调度,以在触发完整GC循环时降低延迟。
6.3。垃圾收集器监控
获得垃圾收集器工作难度的一般概念的简单方法是启用GC日志记录的输出。
始终收集这些统计信息,但通常会被抑制,您可以通过设置GODEBUG
环境变量来启用它们的显示。
% env GODEBUG=gctrace=1 godoc -http=:8080
gc 1 @0.012s 2%: 0.026+0.39+0.10 ms clock, 0.21+0.88/0.52/0+0.84 ms cpu, 4->4->0 MB, 5 MB goal, 8 P
gc 2 @0.016s 3%: 0.038+0.41+0.042 ms clock, 0.30+1.2/0.59/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 3 @0.020s 4%: 0.054+0.56+0.054 ms clock, 0.43+1.0/0.59/0+0.43 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 4 @0.025s 4%: 0.043+0.52+0.058 ms clock, 0.34+1.3/0.64/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 5 @0.029s 5%: 0.058+0.64+0.053 ms clock, 0.46+1.3/0.89/0+0.42 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 6 @0.034s 5%: 0.062+0.42+0.050 ms clock, 0.50+1.2/0.63/0+0.40 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 7 @0.038s 6%: 0.057+0.47+0.046 ms clock, 0.46+1.2/0.67/0+0.37 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 8 @0.041s 6%: 0.049+0.42+0.057 ms clock, 0.39+1.1/0.57/0+0.46 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
gc 9 @0.045s 6%: 0.047+0.38+0.042 ms clock, 0.37+0.94/0.61/0+0.33 ms cpu, 4->4->1 MB, 5 MB goal, 8 P
跟踪输出提供GC活动的一般度量。的输出格式gctrace=1
中描述的runtime
包文档。
DEMO:显示godoc
与GODEBUG=gctrace=1
启用
在生产中使用此env var,它没有性能影响。 |
GODEBUG=gctrace=1
当您知道存在问题时使用很好,但对于Go应用程序的一般遥测,我推荐使用该net/http/pprof
接口。
import _ "net/http/pprof"
导入net/http/pprof
包将/debug/pprof
使用各种运行时指标注册处理程序,包括:
-
所有正在运行的goroutine的列表
/debug/pprof/heap?debug=1
。 -
关于内存分配统计的报告,
/debug/pprof/heap?debug=1
。
请注意,如果您使用,这将是可见的 |
演示:godoc -http=:8080
,显示/debug/pprof
。
6.3.1。垃圾收集器调整
Go运行时提供了一个环境变量来调整GC GOGC
。
GOGC的公式是
g o a l= re a c h a b lë ⋅ ( 1 + G ^ Ö ģ Ç100)GØ一个升=[RË一个CH一个b升Ë⋅(1+GØGC100)
例如,如果我们当前有256MB堆,并且GOGC=100
(默认值),当堆填满时,它将增长到
512 米B = 256 M.乙⋅ ( 1 + 100100)512中号乙=256中号乙⋅(1+100100)
-
GOGC
大于100的值会使堆增长更快,从而降低GC的压力。 -
GOGC
小于100的值会导致堆缓慢增长,从而增加GC的压力。
默认值100是just_a_guide。在使用生产负载分析应用程序后,您应该选择自己的值。
6.4。减少分配
确保您的API允许调用者减少生成的垃圾量。
考虑这两种Read方法
func (r *Reader) Read() ([]byte, error)
func (r *Reader) Read(buf []byte) (int, error)
第一个Read方法不带参数,并返回一些数据作为[]byte
。第二个采用[]byte
缓冲区并返回读取的字节数。
第一个Read方法总是会分配一个缓冲区,给GC带来压力。第二个填充它给出的缓冲区。
你能在std lib中命名这个模式的例子吗?
6.5。字符串和[]字节
在Go中,string
值是不可变的,[]byte
是可变的。
大多数程序都喜欢工作string
,但大多数IO都是完成的[]byte
。
避免[]byte
在可能的情况下进行字符串转换,这通常意味着选择一个表示,a string
或a []byte
表示值。通常情况下,[]byte
如果您从网络或磁盘读取数据。
引擎盖下strings
使用与bytes
包相同的组件原语。
6.6。使用[]byte
作为地图的关键
使用a string
作为地图键是很常见的,但通常你有一个[]byte
。
编译器为此案例实现了特定的优化
var m map[string]string
v, ok := m[string(bytes)]
这将避免将字节切片转换为字符串以进行地图查找。这是非常具体的,如果你这样做,它将无法工作
key := string(bytes)
val, ok := m[key]
让我们看看这是否仍然存在。编写一个基准,比较使用a []byte
作为string
映射键的这两种方法。
6.7。避免字符串连接
Go字符串是不可变的。连接两个字符串会产生第三个字符串。以下哪项最快?
s := request.ID
s += " " + client.Addr().String()
s += " " + time.Now().String()
r = s
var b bytes.Buffer
fmt.Fprintf(&b, "%s %v %v", request.ID, client.Addr(), time.Now())
r = b.String()
r = fmt.Sprintf("%s %v %v", request.ID, client.Addr(), time.Now())
b := make([]byte, 0, 40)
b = append(b, request.ID...)
b = append(b, ' ')
b = append(b, client.Addr().String()...)
b = append(b, ' ')
b = time.Now().AppendFormat(b, "2006-01-02 15:04:05.999999999 -0700 MST")
r = string(b)
var b strings.Builder
b.WriteString(request.ID)
b.WriteString(" ")
b.WriteString(client.Addr().String())
b.WriteString(" ")
b.WriteString(time.Now().String())
r = b.String()
DEMO: go test -bench=. ./examples/concat
6.8。如果长度已知,则预分配切片
追加方便,但浪费。
切片增加倍数达到1024个元素,然后增加约25%。b
在我们追加一件商品之后的容量是多少?
func main() {
b := make([]int, 1024)
b = append(b, 99)
fmt.Println("len:", len(b), "cap:", cap(b))
}
如果使用追加模式,则可能会复制大量数据并造成大量垃圾。
如果事先知道切片的长度,则预先分配目标以避免复制并确保目标的大小正确。
var s []string
for _, v := range fn() {
s = append(s, v)
}
return s
vals := fn()
s := make([]string, len(vals))
for i, v := range vals {
s[i] = v
}
return s
6.9。使用sync.Pool
该sync
软件包附带一个sync.Pool
用于重用常见对象的类型。
sync.Pool
没有固定的大小或最大容量。您添加它并从中取出直到GC发生,然后无条件地清空它。这是设计的:
如果在垃圾收集过早之前和垃圾收集太晚之后,那么排空池的正确时间必须在垃圾收集期间。也就是说,Pool类型的语义必须是它在每个垃圾收集时消失。 - 拉斯考克斯
var pool = sync.Pool{New: func() interface{} { return make([]byte, 4096) }}
func fn() {
buf := pool.Get().([]byte) // takes from pool or calls New
// do work
pool.Put(buf) // returns buf to the pool
}
不要将重要物品放入 |
在每个GC上清空自己的sync.Pool的设计可能会在Go 1.13中改变,这将有助于提高其效用。
|
6.10。演习
-
使用
godoc
(或其他程序)观察更改GOGC
使用的结果GODEBUG=gctrace=1
。 -
基准字节的字符串(字节)映射键
-
基准来自不同的concat策略。
7.提示和旅行
随机抓取提示和建议
最后一节包含一些微优化Go代码的技巧。
7.1。够程
Go的关键特性使其非常适合现代硬件,这些都是goroutines。
Goroutines很容易使用,而且创建起来很便宜,你可以认为它们几乎是免费的。
Go运行时是为具有成千上万个goroutines的程序编写的,数十万不是意料之外的。
但是,每个goroutine确实消耗了goroutine堆栈的最小内存量,目前至少为2k。
2048 * 1,000,000 goroutines == 2GB的内存,他们还没有做任何事情。
也许这是很多,也许它没有给出你的应用程序的其他用法。
7.1.1。知道什么时候停止goroutine
Goroutines起步便宜且运行成本低廉,但它们在内存占用方面的成本确实有限; 你无法创造无限数量的它们。
每次go
在程序中使用关键字来启动goroutine时,都必须知道 goroutine将如何以及何时退出。
在您的设计中,一些goroutine可能会运行直到程序退出。这些goroutine很少见,不会成为规则的例外。
如果您不知道答案,那就是潜在的内存泄漏,因为goroutine会将其堆栈的内存固定在堆上,以及从堆栈可以访问的任何堆分配的变量。
永远不要在不知道如何停止的情况下启动goroutine。 |
7.1.2。进一步阅读
-
并发变得容易(视频)
-
并发变得容易(幻灯片)
-
如果不知道它什么时候停止就不要开始goroutine(Practical Go,QCon Shanghai 2018)
7.2。Go对某些请求使用高效的网络轮询
Go运行时使用高效的操作系统轮询机制(kqueue,epoll,windows IOCP等)处理网络IO。许多等待的goroutine将由单个操作系统线程提供服务。
但是,对于本地文件IO,Go不实现任何IO轮询。a上的每个操作*os.File
在进行时消耗一个操作系统线程。
大量使用本地文件IO会导致程序产生数百或数千个线程; 可能超过您的操作系统允许。
您的磁盘子系统不希望能够处理数百或数千个并发IO请求。
要限制并发阻塞IO的数量,请使用worker goroutines池或缓冲通道作为信号量。
|
7.3。注意应用程序中的IO乘数
如果您正在编写服务器进程,那么它的主要工作是复用通过网络连接的客户端以及存储在应用程序中的数据。
大多数服务器程序接受请求,进行一些处理,然后返回结果。这听起来很简单,但根据结果,它可以让客户端在服务器上消耗大量(可能无限制)的资源。以下是一些需要注意的事项:
-
每个传入请求的IO请求数量; 单个客户端请求生成多少个IO事件?如果从缓存中提供多个请求,则它可能平均为1,或者可能小于1。
-
服务查询所需的读取量; 它是固定的,N + 1还是线性的(读取整个表格以生成结果的最后一页)。
如果内存很慢,相对来说,那么IO太慢了,你应该不惜一切代价避免这样做。最重要的是避免在请求的上下文中执行IO - 不要让用户等待磁盘子系统写入磁盘,甚至不要读取。
7.4。使用流式IO接口
尽可能避免将数据读入[]byte
并传递给它。
根据请求,您最终可能会将兆字节(或更多!)的数据读入内存。这给GC带来了巨大压力,这将增加应用程序的平均延迟。
而是使用io.Reader
和io.Writer
构造处理管道来限制每个请求使用的内存量。
为了提高效率,请考虑实施io.ReaderFrom
/ io.WriterTo
如果您使用了很多io.Copy
。这些接口更有效,并避免将内存复制到临时缓冲区。
7.6。推迟是昂贵的,或者是它?
defer
是昂贵的,因为它必须记录延迟的论点的闭包。
defer mu.Unlock()
相当于
defer func() {
mu.Unlock()
}()
defer
如果正在完成的工作量很小,那么经典的例子就是defer
围绕结构变量或地图查找进行互斥锁解锁。defer
在这些情况下,您可以选择避免。
这是为了获得性能而牺牲可读性和维护性的情况。
始终重新审视这些决定。
7.7。避免终结者
最终化是一种将行为附加到即将被垃圾收集的对象的技术。
因此,最终确定是非确定性的。
要运行终结器,任何东西都不能访问该对象。如果您不小心在地图中保留了对象的引用,则无法完成。
终结者作为gc循环的一部分运行,这意味着它们在运行时是不可预测的,并且使它们与减少gc操作的目标不一致。
如果你有一个大堆并且已经调整你的应用程序来创建最小的垃圾,终结者可能不会运行很长时间。
7.8。最小化cgo
cgo允许Go程序调用C库。
C代码和Go代码存在于两个不同的Universe中,cgo遍历它们之间的边界。
这种转换不是免费的,取决于代码中的位置,成本可能很高。
cgo调用类似于阻塞IO,它们在操作期间消耗一个线程。
不要在紧密循环中调用C代码。
7.8.1。实际上,也许避免使用cgo
cgo的开销很高。
为获得最佳性能,我建议您在应用程序中避免使用cgo
-
如果C代码需要很长时间,那么cgo开销就不那么重要了。
-
如果你正在使用cgo来调用一个非常短的C函数,其中开销是最明显的,那么在Go中重写该代码 - 根据定义它很短。
-
如果您使用大量昂贵的C代码在紧密循环中调用,为什么使用Go?
是否有人使用cgo经常拨打昂贵的C代码?
7.9。始终使用最新发布的Go版本
Go的旧版本永远不会变得更好。他们永远不会得到错误修复或优化。
-
不应该使用Go 1.4。
-
Go 1.5和1.6的编译器速度较慢,但它产生更快的代码,并且具有更快的GC。
-
Go 1.7的编译速度比1.6提高了大约30%,链接速度提高了2倍(优于之前的Go版本)。
-
Go 1.8将提高编译速度(此时),但非英特尔架构的代码质量有了显着提高。
-
转到1.9-1.12继续提高生成代码的性能,修复错误,改进内联并改进debuging。
Go的旧版本没有收到任何更新。不要使用它们。使用最新版本,您将获得最佳性能。 |
7.10。讨论
任何问题?
最后的问题和结论
可读性意味着可靠 - Rob Pike
从最简单的代码开始。
测量。描述您的代码以识别瓶颈,不要猜测。
如果表现良好,请停止。您不需要优化所有内容,只需要优化代码中最热门的部分。
随着应用程序的增长或流量模式的发展,性能热点将会发生变化。
不要留下对性能不重要的复杂代码,如果瓶颈移到其他地方,则用更简单的操作重写它。
总是编写最简单的代码,编译器针对普通代码进行了优化。
更短的代码是更快的代码; Go不是C ++,不要指望编译器解开复杂的抽象。
更短的代码是更小的代码; 这对CPU的缓存很重要。
密切关注分配,尽可能避免不必要的分配。
如果他们不必正确,我可以把事情做得很快。 - 拉斯考克斯
性能和可靠性同样重要。
我认为制作速度非常快的服务器,定期出现恐慌,死锁或OOM的价值不大。
不要为了可靠性而交易性能。
本文来自博客园,作者:sunsky303,转载请注明原文链接:https://www.cnblogs.com/sunsky303/p/11077634.html