下面显示的代码在性能上是否等效?
// (A). HasPrefix . return strings.HasPrefix(s, "#") // (B). HasPrefix. return len(s) >= len("#") && s[:len("#")] == "#"
答案是否定的 。
有关详细信息和解释,请询问。
美好的一天,在您打开主题之前,我想自我介绍一下。
我的名字叫Iskander,我不时将提交内容发送到golang / go存储库。

我曾经代表Intel Go团队进行此操作,但是我们的道路各不相同,现在我是一名独立贡献者。 最近,我在vk的基础架构团队中工作。
在业余时间,我会为Go开发不同的工具,例如go-critic和go-consistent 。 我也画地鼠 。
衡量吧!
立即进行比较并概述基准:
package benchmark import ( "strings" "testing" ) var s = "#string" func BenchmarkHasPrefixCall(b *testing.B) { for i := 0; i < bN; i++ { _ = strings.HasPrefix(s, "#") _ = strings.HasPrefix(s, "x") } } func BenchmarkHasPrefixInlined(b *testing.B) { for i := 0; i < bN; i++ { _ = len(s) >= len("#") && s[:len("#")] == "#" _ = len(s) >= len("x") && s[:len("x")] == "x" } }
除了向您推荐Benchstat之外 ,我将向您展示Benchrun 。
使用一个命令,我们可以运行两个基准测试并进行比较:
go-benchrun HasPrefixCall HasPrefixInlined -v -count=10 . Benchstat results: name old time/op new time/op delta HasPrefixCall-8 9.15ns ± 1% 0.36ns ± 3% -96.09% (p=0.000 n=10+9)
带有手动嵌入的选项比通过将函数主体嵌入到编译器中所获得的代码要快得多。 让我们尝试弄清楚为什么会这样。
strings.HasPrefix
回忆一下string.HasPrefix的实现:
// HasPrefix tests whether the string s begins with prefix. func HasPrefix(s, prefix string) bool { return len(s) >= len(prefix) && s[0:len(prefix)] == prefix }
HasPrefix
函数由编译器内置。
您可以如下验证:
go build -gcflags='-m=2' strings 2>&1 | grep 'can inline HasPrefix'
要从选项(A)
调用strings.HasPrefix
,我们获得以下机器代码:
MOVQ (TLS), CX CMPQ SP, 16(CX) JLS more_stack fn_body: SUBQ $40, SP MOVQ BP, 32(SP) LEAQ 32(SP), BP XCHGL AX, AX MOVQ s+56(SP), AX CMPQ AX, $1 JGE compare_strings XORL AX, AX MOVB AL, ~ret1+64(SP) MOVQ 32(SP), BP ADDQ $40, SP return: RET compare_strings: MOVQ s+48(SP), AX MOVQ AX, (SP) LEAQ go.string."#"(SB), AX MOVQ AX, 8(SP) MOVQ $1, 16(SP) CALL runtime.memequal(SB) MOVBLZX 24(SP), AX JMP return more_stack: CALL runtime.morestack_noctxt(SB) JMP fn_body
忽略代码看起来像面条的事实。
您要注意的是:
strings.HasPrefix
确实已插入,没有调用。- 为了比较字符串,
runtime.memequal
。
但是,对于手动内置版本(B)
示例(B)
的代码(B)
什么?
MOVQ s+16(SP), AX CMPQ AX, $1 JLT different_length MOVQ s+8(SP), AX CMPB (AX), $35 // 35 - "#" SETEQ AL return: MOVB AL, "".~ret1+24(SP) RET different_length: XORL AX, AX JMP 22
在这里,编译器不会生成对runtime.memequal
的调用,而是直接比较单个字符。 理想情况下,他应该为第一个选择做同样的事情。
我们观察了Go优化器的弱点,我们将对其进行分析。
常量表达式优化
之所以可以优化调用strings.HasPrefix(s, "#")
的原因,是因为prefix参数是一个常量。 我们知道它的长度和内容。 对于短字符串,调用runtime.memequal
毫无意义,将字符进行“就地”比较会更快,从而避免了额外的调用。
如您所知,编译器通常至少包含两部分: 编译器前端和编译器后端 。 第一个使用较高级别的视图,第二个使用更靠近机器的视图,中间视图看起来像是指令流。 Go的多个版本已经使用SSA表示形式在编译器的后端部分进行了优化。
在后端实现恒定折叠,例如{10*2 => 20}
。 通常,与降低表达式的计算成本相关的大多数操作都位于编译器的这一部分。 但是也有例外。
一种例外是常量字符串比较的优化。 当编译器看到一个或两个操作数都是常量的字符串(或子字符串)比较时,生成的代码比对runtime.memequal
的调用更有效。
您可以在cmd / compile / internal / gc / walk.go:3362文件中查看负责此工作的源代码。
函数嵌入发生在启动这些优化之前,但也发生在编译器的前端部分。
似乎所有这些都不允许这种优化在我们的情况下起作用吗?
Go如何嵌入函数调用
嵌入的过程如下:
// : return strings.HasPrefix(s, "#") // : func HasPrefix(s, prefix string) bool // : _s, _prefix := s, "#" return len(s) >= len(prefix) && s[:len(prefix)] == prefix
嵌入函数时,编译器会将参数分配给临时变量,这会破坏优化,因为walk.go中的算法看不到常量,而是带有变量的参数。 那就是问题所在。
顺便说一下,这不会干扰SSA可以使用的后端优化。 但是那里还有其他问题,例如,无法还原高级语言结构以进行有效的比较(消除这种缺陷的工作已经“计划”了好几年了)。
另一个例子:逃逸分析
想象一下对于在堆栈上分配临时缓冲区很重要的函数:
func businessLogic() error { buf := make([]byte, 0, 16) // buf // . return nil }
由于buf
不会“运行”,因此编译器将能够在堆栈上分配这16个字节,而无需在堆上分配。 再次感谢所有在调用make
时的常量值。 要在堆栈上分配内存,对于我们来说重要的是要知道所需的大小,这将是分配给函数调用的帧的一部分。
假设将来我们想分配不同大小的临时缓冲区,并在方法中封装一些逻辑。 我们引入了一个新的抽象,并决定在tmpBuf
使用新的tmpBuf
类型。 设计功能非常简单:
func newTmpBuf(sizeHint int) tmpBuf { return tmpBuf{buf: make([]byte, 0, sizeHint)} }
改编原始示例:
func businessLogic() error { - buf := make([]byte, 0, 16) + buf := newTmpBuf(16) // buf // . return nil }
构造函数将被嵌入,但是分配现在总是在堆上,原因与参数通过临时变量传递的原因相同。 转义分析将发现make([]byte, 0, _sizeHint)
不在优化的make
调用的识别模式之内。
如果我们拥有“一切都像人类”,那么在嵌入newTmpBuf
构造函数之后,问题就不存在了,显然在编译阶段仍然知道大小。
这几乎比比较字符串更令人沮丧。
地平线去1.13

如果您认为本文中描述的问题确实需要解决,请竖起相应的问题 。
我的立场是,仅仅因为在当前版本的Go中可以更快地运行代码而嵌入代码是错误的。 有必要在优化器中修复此缺陷,至少应解决上述示例在没有意外性能下降的情况下进行的工作。
如果一切按计划进行,则此优化将包含在Go 1.13版本中。
谢谢您的关注。
补充:建议的解决方案
本部分适用于最勇敢的人,那些不厌倦阅读的人。
因此,当直接使用变量而不是它们的值时,我们有几个地方工作得更糟。 提出的解决方案是在编译器部分的前端引入一个新函数,该函数允许您按名称获取最后一个绑定值。 之后,在每个期望恒定值的优化中,不要在检测到变量时放弃,而要接收此先前保存的状态。
我们新功能的签名可能如下所示:
func getConstValue(n *Node) *Node
Node
的定义可以在syntax.go文件中找到。
每个变量定义都有一个带有ONAME
标记的Node
标记。 在Node.Name.Defn
大多数这些变量都有一个初始化值。
如果Node
已经是文字,那么您无需执行任何操作,而我们只返回n
。 如果这是ONAME
(变量),则可以尝试从n.Name.Defn
提取相同的初始化值。
但是,在声明和读取我们称为getConstValue
的变量之间进行的修改getConstValue
如何呢? 如果我们将自己限制为只读变量,那么就没有问题。 Go的前端已经有特殊的节点标记,用于标记相似的名称。 如果已修改变量,则getConstValue
将不会返回初始化值。
程序员通常不会修改数字和字符串类型的输入参数,因此使用此原始算法可以覆盖相当多的情况。
现在我们准备考虑实现了:
func getConstValue(n *Node) *Node { // ONAME definition. if n.Op != ONAME || n.Name.Defn == nil { return n } // , . // , , // escape analysis' . maybeModified := n.Assigned() || n.Name.Defn.Assigned() || n.Addrtaken() if maybeModified { return n } // OAS - Node . // n.Name.Defn.Left - LHS. // n.Name.Defn.Right - RHS. // consttype(v) . // CTxxx, . if n.Name.Defn.Op == OAS { v := n.Name.Defn.Right if v != nil && consttype(v) != CTxxx { return v } } return n }
这是代码的更改方式,具体取决于常量:
- i := indexconst(r) + i := indexconst(getConstValue(r))
很好,它甚至可以工作:
n := 10 xs := make([]int, n) // !
在进行此更改之前,转义分析无法获得值10
到n
,这就是为什么我假设需要在堆上放置xs
的原因。
上面的代码在语法上类似于嵌入期间观察到的情况。 n
可以是在传递参数时添加的临时变量。
不幸的是,有细微差别。
我们解决了通过OAS引入的局部变量的问题,但是Go通过OAS2初始化了内置函数的变量。 因此,我们需要进行第二次更改,以扩展getConstValue
函数并稍微修改内联代码本身的代码,因为除其他外, OAS2
没有合适的Defn
字段。
那真是个坏消息。 好消息: #gocontributing频道出现在俄语中 ,您可以在其中分享您的想法和计划,提出问题并讨论与参与Go开发有关的所有事情。