Go编译器需要更智能

2020-06-05 11:47:53

我最喜欢的语言之一是围棋语言。我喜欢它的简约。它在云环境中很受欢迎,也很有用。许多流行的工具都是用Go编写的,这是有充分理由的。

去年我做了一次关于围棋的演讲,有人要求我对围棋进行批评。我不介意围棋缺少例外或泛型。这些功能通常被高估了。

然而,尽管Go很有魅力,但我发现它的编译器与我对其他编程语言的期望不一样。当围棋还年轻和不成熟的时候,这是可以原谅的。但我认为围棋选手现在需要解决这些问题。

我对编译器的第一个不满是它在内联方面很害羞。内联是将一个函数带入另一个函数,而不需要函数调用的过程。即使函数调用成本不高,内联通常也会带来很多好处。

随着时间的推移,围棋改进了它的内联策略,但我仍然会把它描述为“过于害羞”。

让我考虑下面的示例,其中我首先定义了一个对数组中的元素求和的函数,然后对刚刚定义的数组调用此函数:

func sum(x[]uint64)uint64{var sum=uint64(0)for_,v:=range x{sum+=v}return sum}func Fun()uint64{x:=[]uint64{10,20,30}return sum(X)}。

无论您使用的是RUST、SWIFT、C、C++…。您期望一个好的优化编译器基本上内联对“sum”函数的调用,然后计算出答案可以在编译时确定,并将“Fun”函数优化为一些微不足道的东西。

在实践中,这意味着如果你想在围棋中有好的表现,你经常必须手动内联你的函数。我的意思是:完全内联。如果希望GO编译器优化计算,则必须编写一个非常显式的函数,如下所示:

func fun3()uint64{x:=[3]uint64{10001,21,31}返回x[0]+x[1]+x[2]}。

我对Go语言的第二个担忧是,它没有运行时常量变量的真正概念。也就是说,您有编译时常量,但是如果您有一个变量在程序的生命周期中只设置一次,并且永远不会更改,那么Go仍然会将其视为可以更改。编译器对您没有帮助。

让我们举个例子。Go添加了很好的功能,让您可以访问快速处理器指令。例如,大多数x64处理器都有一条popcnt指令,它为您提供64位字中的数字1位。过去,在围棋中访问这条指令的唯一方式是编写汇编语言。已经解决了。所以让我们把这个代码付诸行动:

此函数应返回2,因为提供的值(1和2)都恰好设置了一位。我打赌大多数C/C++编译器都能解决这个问题。但是我们可以原谅我没有去那里。

在使用popcnt指令之前,GO需要检查处理器是否支持它。当您启动GO时,它会查询处理器并用此知识填充一个变量。这可以在编译时完成,但是当在不支持popcnt的处理器上运行时,您的二进制文件可能会崩溃,甚至更糟。

在具有实时编译的语言(如Java或C#)中,处理器在编译时被检测到,因此不需要检查。在C或C++这样不那么奇特的语言中,程序员需要检查处理器自己支持什么。

我可以原谅我检查一下,每次调用“愚蠢”函数时是否支持popcnt。但这不是围棋要做的事情。去检查两次:

cmpb运行时.x86HasPOPCNT(SB),$0 jeq tosy_pc115 movl$1,ax popcntq ax,ax cmpb运行时.x86HasPOPCNT(SB),$0 jeq ny_pc85 movl$2,CX popcntq CX,CX。

这是因为编译器不信任或无法确定变量“运行时.x86HasPOPCNT”是运行时常量。

有些人会反对这样的支票不贵。我认为这一观点应该受到挑战:

正如我提供的汇编代码中很明显的那样,您可能会将所需的指令数增加一倍或至少增加50%。比较和跳转很便宜,但popcnt也是如此(有些处理器每个周期可以停用两个popcnt!)。增加指令数量会使代码变慢。

确实,处理器很可能正确地预测分支/跳转。这使得保护代码比有时可能被错误预测的分支便宜得多。但这并不意味着您没有受到伤害:即使在不可能删除所有分支的情况下,减少“几乎总是采用”或“几乎从不采用”的分支数量可能有助于处理器更好地预测剩余的分支。路透社(…)。对于这种现象,一种可能的简化解释是,处理器使用最近分支的历史来预测未来的分支。缺乏信息的分支可能会降低处理器做出良好预测的能力。

Go的优点在于,它使得将汇编代码集成到代码库中变得容易。因此,您可以用C语言编写对性能至关重要的代码,编译它,并在Go项目中使用结果。例如,我们在咆哮中就是这样做的。人们已经在GO中移植了非常快的Stream VByte编码和非常快的simdjson解析器,同样是使用汇编。它起作用了。

然而,它使得大部分围棋软件的运行性能只有一个很好的优化编译器所能达到的性能的一小部分。

附录:使用gccgo编译go可以解决这些特殊问题。然而,据报道,gccgo的整体表现更差。