挑战:使这个Go函数可内联且无边界检查
在这篇文章中,我挑战你重构一个小的Go函数,使其可内联且无边界检查,以获得更好的性能。
免责声明:本文假设使用(官方)Go编译器的1.24.2版本;使用其他版本的Go编译器或其他Go语言实现可能会得到不同的结果。
函数内联与边界检查消除
尝试我的挑战需要一些函数内联和边界检查消除的基础知识。以下三个部分作为这些主题的速成课程。如果需要,可以直接跳到挑战本身。
函数内联101
内联是一种编译器策略,大致可以描述为“用函数体替换对该函数的调用”。因为它消除了一些函数调用开销,内联通常会导致更快的程序执行。然而,函数内联也会增加生成的二进制文件的大小,不加选择的内联可能导致编译器产生过大的二进制文件。因此,编译器通常将内联资格限制在它们认为“足够简单”的函数上。
对于每个函数,Go编译器分配一个内联预算并计算内联成本。如果函数的内联成本不超过其预算,编译器认为该函数有资格内联(即可内联)并内联它;否则,不会内联。在撰写本文时(Go 1.24.2),默认内联预算是80,但编译器可能由于配置文件引导的优化(PGO)而决定增加热路径上调用的函数的内联预算;对于本文,让我们保持简单,忽略PGO。然而,函数中存在某些语言结构(如递归、“go”语句、“defer”语句和调用recover函数)会直接阻止其被内联。Go编译器的内联策略不是由语言本身指定的,可能会从一个版本到下一个版本发生变化。
确定函数是否有资格内联的最直接方法是运行以下形式的命令并检查其输出:
|
|
通过重构最初不可内联的函数(和/或放宽其语义),有时可以降低函数的内联成本,使其有资格内联;然而,除非你非常熟悉Go编译器的内联器(并跟上Go项目维护者对其所做的更改),否则你可能会(正确地)觉得使函数有资格内联更像是一门艺术而不是科学。
边界检查消除101
我在今年早些时候发表在这篇博客上的文章中提到了边界检查消除(BCE);请允许我引用它:
[…] Go被称为内存安全;特别是,根据语言规范,如果切片索引操作越界,实现必须触发运行时恐慌。这样的边界检查相对便宜,但并非免费。当编译器可以证明某些切片访问不会越界时,它可能会为了更好的性能而从生成的可执行文件中省略相应的边界检查。
我在那篇文章中写的关于切片的内容也适用于字符串,它们在底层不过是字节切片。
边界检查消除,就像内联策略一样,仍然是一个实现细节;它不是由语言本身指定的,可能会从一个Go编译器版本到下一个版本发生变化。
要识别Go编译器在包中引入边界检查的位置,请运行以下形式的命令:
|
|
边界检查的存在并不总是对性能有害,而且从程序中消除所有边界检查通常被证明是不可能的。然而,消除一些边界检查,也许通过将它们提升到循环之外,通常有助于更快的程序执行。你可以在Go的标准库中找到将边界检查提升到循环之外的实例。
尽管Go编译器的BCE逻辑经常改进,但有时你需要重构代码以说服编译器消除或移动一些边界检查。这也更像是一门艺术而不是科学,通常需要一些试验和错误。
微妙的平衡行为
函数内联和BCE紧密相连。可内线性有时以几个额外的边界检查为代价;相反,消除一些边界检查可能会剥夺函数的内联资格。但在一些幸运的情况下,你可以两全其美!
这里有一个我从经验中学到的提示:首先消除边界检查,然后实现可内线性,往往比反过来或一次性完成两者更容易。
你能使这个函数可内联且无边界检查吗?
考虑以下源文件(我已上传到Gist,方便你使用):
|
|
上面的TrimOWS实现不会产生边界检查,如以下命令的空输出所示:
|
|
然而,它不可内联:
|
|
这是我的挑战:你能重构函数TrimOWS,使其可内联(不依赖配置文件引导的优化)且无边界检查吗?
为了让你在重构代码时捕捉错误,我包含了一套单元测试;为了让你以后能够测量实现之间的性能变化,我还包含了一些基准测试:
|
|
准备好进行一些微优化了吗?克隆Gist并cd到你的克隆:
|
|
开始吧!
提示
这里有一系列提示,如果你卡住了可能会觉得有用;根据需要点击每个提示来揭示它。
提示0
尝试消除不必要的分支。特别是,TrimOWS中的初始空字符串检查是多余的;这是一种微优化尝试,但其性能好处是模糊的。此外,TrimOWS不需要检查trimRightOWS的布尔结果;相反,它可以简单地将trimRightOWS的结果冒泡给调用者。
|
|
这些更改没有引入任何边界检查,并稍微降低了TrimOWS的内联成本:
|
|
提示1
尽管辅助函数trimLeftOWS和trimRightOWS本身是可内联的,但在TrimOWS中手动内联它们有助于可内线性而不损害可读性:
|
|
这些更改不仅没有引入任何边界检查,而且显著降低了TrimOWS的内联成本:
|
|
手动内联我的小isOWS函数在其两个调用站点是诱人的,并且确实稍微降低了TrimOWS的内联成本,但这样做并不理想,因为它违反了DRY原则:
每个知识在系统中必须有一个单一、明确、权威的表示。
提示2
尝试遍历字符串而不是反复削减它:
|
|
不幸的是,这些更改有些适得其反,因为它们不仅增加了TrimOWS的内联成本,还引入了边界检查!
|
|
别担心!性能优化很少是一条直线,最初被视为挫折的重构实际上可能解锁新的改进机会。
提示3
编译器觉得需要引入边界检查,因为它(还?)没有跟踪足够的上下文来意识到j总是在s的边界内。为了帮助编译器,我们可以在循环内子串并重新分配s,而不是稍后;这个更改还允许我们减少控制循环的两个整数变量的范围,并简单地在执行到达TrimOWS底部时返回空字符串:
|
|
边界检查,消失吧!
|
|
TrimOWS的内联成本再次向错误的方向移动:
|
|
再次,不要让这个暂时的挫折阻止你。小步前进!
提示4
尽可能使用range-over-int循环,因为它们往往比经典的三子句循环更便宜(且更容易推理!):
|
|
没有引入边界检查,TrimOWS的内联成本现在是迄今为止最低的:
|
|