早在2018年,我们就低级调用约定从经验上比较了Goand C ++的性能开销:传递参数,返回多个值以及传播异常时。
这些结果是使用Go 1.10和Clang 6.0获得的。从那时起,编译器不断发展,我们现在使用Go 1.15和Clang11.0运行。从那以后发现有变化吗?
什么是便宜的:通过err:= ...通过panic / recover,通过和测试错误结果来处理异常。 err!= nil {return err}?
实际上,正如我们将在下面看到的,今天的结论比两年前要强。
首先请读者熟悉Go的低级调用约定与本系列前几章中的C ++的讨论:从2018年和2020年开始。2018年的分析在2020年仍然有效-C ++调用约定没有改变,Go对defer的实现进行了一些优化。
为了测量参数传递和返回多个值的方法,我们将在此处和此处使用与前两个分析完全相同的方法。
这些分析包含其基准源代码,该源代码也在github.com/knz/callbench中提供。通过Jupyter笔记本可以完全自动化地块的绘制,因此我们可以重新运行它。
对于第三种分析,它测量并比较了错误返回的成本与引发和捕获异常的成本,我们将重用相同的方法,但是使用差异源作为基准。新的源代码也已上传到github.com/knz/callbench。这种差异是由以下原因引起的。
从2018年开始,这里的发现基本上没有变化:对于选定的基准代码,我们选定的编译器生成的代码没有显着变化。
只有新的硬件CPU会影响测量:新CPU的时钟频率比以前更高,因此对于相同的指令混合,我们期望并观察到10-40%的原始性能改进。此外,Ryzen 7 3950X架构具有比1800X更强大的超标量执行单元,并且能够检测更多内存访问之间的指令间依赖性。当工作负载极小时,这会导致更多的测量失真,并消除1和5函数参数或返回值之间的性能差异。
Go使用内存传递函数参数并返回值。这使得Go代码在功能调用上的执行速度比等效的C ++代码慢大约两倍,在C ++编译器中,C ++编译器使用基于寄存器的调用约定。
我们今年使用的特定CPU使用的微体系结构能够更积极地优化内存密集型代码,因此对于非常小的功能,可以消除相对于C ++的Goperformance开销。不幸的是,这些优化随着更现实,更广泛的功能主体而变得无效。
在Go中,即使只有一个或两个字的数据,内存也用于返回值。在这些情况下,C ++编译器通常还会使用寄存器。 Go编译器的这种选择很不幸,因为当函数在级联中返回彼此的值时,它要求在每个调用级别通过内存复制返回值。因此,这笔费用将基于内存的返回的开销乘以调用深度,这是典型的C ++代码生成器所不会发生的或几乎没有的方式。
如前所述,我们可以讨论函数调用相对于其他类型代码的比例。可以说,运算量大的算法通常会内联函数,从而使调用消失。在这种情况下,在CPU微体系结构的协助下,Go编译器有可能达到与等效C ++代码相似的指令吞吐量。
但是,我们还可以指出,Go仍在推动动态调度(通过接口方法)作为软件设计的惯用方法。例如,通过字节组成字符串。缓冲区使用接口调用(io.Writer)与fmt包进行接口,并且在这种情况下,与计算负载相比,跨接口调用的比例相对较高。
邀请读者查看之前的分析中更详细的摘要,其中更详细地介绍了这种情况。
该分析需要一个基准,该基准可以在一个循环中执行``工作单元'',并且循环的大小可以配置为``工作量''。
我们正在对一种非常普遍的情况建模,即一个工作单元内的任何错误都将中止整个工作负载:该错误可能会或可能不会在工作单元内生成,但是对于整个外部工作负载循环只需要检测一次即可。
// go:noinline func leafErr(arg int)(int,error){如果arg == 0 {// //不太可能出现错误。 return 0,errObj} //常见的非错误情况。 return id(arg),nil} // go:noinline func workErr(work int)int {var n int for i:= 0;我<工作; i ++ {val,err:= leafErr(work)if err!= nil {return 42} n + = val} return n}
在此代码中,leafErr代表“工作单元”,可能在其中产生错误。 workErr表示主要的工作计算,其中leafErr被重复调用。
在使用错误返回的情况下,我们需要在循环的每次迭代中检查err返回。如果我们使用异常代替,我们可以将异常检查从循环中分解出来:
// // go:noinline func workExc(work int)(res int){//异常检查不在循环中:defer func(){如果r:= restore(); r!= nil {res = 42}}()//主要工作量如下:var n int for i:= 0;我<工作; i ++ {n + = leafExc(work + 1)}返回n} // go:noinline func leafExc(arg int)int {if arg == 0 {//错误情况。 panic(errObj)} //常见的非错误情况。返回ID(arg)}
(随附的C ++代码以类似的方式实现,使用std :: tuple返回错误并使用try / catch / throw进行异常)。
仅查看源代码的形状,我们就已经可以怀疑接下来的发现:使用异常时,工作负载循环中的工作量将减少。因此,随着工作负载的增加,摊销``捕获''逻辑的成本将摊销。
但是,在2020年使用Go 1.15在我们的新测试机上进行相同的实验时,测量结果到处都是,结果看起来很混乱,并且我们上次观察到的差异几乎没有被完全消除。发生了什么?
事实证明,用于运行基准测试的新CPU确实具有更好的微体系结构。它能够浏览从工作函数到叶子函数的调用,分析整个调用之间的指令间依赖性,并在工作循环的多次迭代中完全流水线处理叶子函数。这有效地使我们使用的模型无效:在区域应用程序中,“工作负载”中的代码包含足够的复杂性(当包含函数调用时),CPU无法管道循环迭代。
为了恢复有效的模型,我们应该通过在“工作单元”中添加足够的额外复杂性来调整基准程序。在这里,我们通过添加一个额外的中间调用级别来实现这一目标:
// go:noinline func leafErr(arg int)(int,error){如果arg == 0 {// //不太可能出现错误。 return 0,errObj} //常见的非错误情况。 return id(arg),nil} // go:noinline func middleErr(arg int)(int,error){return leafErr(arg)} // go:noinline func workErr(work int)int {var n int for i: = 0;我<工作; i ++ {val,err:= middleErr(work)if err!= nil {return 42} n + = val} return n}
该程序(至少在所考虑的CPU上)足够“复杂”,可以阻止超标量单元窥视函数调用。结果代码随后在测量中表现正常。
该实验的Jupiter笔记本包含数据处理步骤和生成比较图的逻辑,并对中间结果进行了一些详细的分析。
对于等效的工作负载(例如1000个“工作单元”),Go代码比等效的C ++代码慢40%。通常预计这是因为Go比C ++占用更多的内存。
无论使用哪种语言,都可以在整个工作量中可靠地摊销设置异常“捕获”的成本:使用基于异常的错误报告的代码性能渐近地收敛到与不使用任何错误处理的代码相同。
相比之下,随着工作量的增加,随着返回值传播错误的代码在系统上变慢。 C ++介于1-3%之间,Go高达4-10%之间。
设置例外“捕获”的成本并非可以忽略不计。早在2018年,工作功能中的defersetup和测试restore()的强制性固定成本为50ns;因此花费约500纳秒的``工作量''来完全抵消这一一次性成本。
到2020年,由于Go 1.15中的延迟优化,固定开销大大减少了;只需几条指令,在当前的测试硬件上只有11ns。现在仅需花费35纳秒的“工作量”即可完全抵消一次性成本。