是啥让这个函数运行得慢得多?

Posted

技术标签:

【中文标题】是啥让这个函数运行得慢得多?【英文标题】:What makes this function run much slower?是什么让这个函数运行得慢得多? 【发布时间】:2015-07-29 10:46:59 【问题描述】:

我一直在尝试做一个实验,看看函数中的局部变量是否存储在堆栈中。

所以我写了一点性能测试

function test(fn, times)
    var i = times;
    var t = Date.now()
    while(i--)
        fn()
    
    return Date.now() - t;
 
ene
function straight()
    var a = 1
    var b = 2
    var c = 3
    var d = 4
    var e = 5
    a = a * 5
    b = Math.pow(b, 10)
    c = Math.pow(c, 11)
    d = Math.pow(d, 12)
    e = Math.pow(e, 25)

function inversed()
    var a = 1
    var b = 2
    var c = 3
    var d = 4
    var e = 5
    e = Math.pow(e, 25)
    d = Math.pow(d, 12)
    c = Math.pow(c, 11)
    b = Math.pow(b, 10)
    a = a * 5

我希望反函数的工作速度更快。相反,一个惊人的结果出来了。

在我测试其中一个函数之前,它的运行速度比测试第二个函数快 10 倍。

例子:

> test(straight, 10000000)
30
> test(straight, 10000000)
32
> test(inversed, 10000000)
390
> test(straight, 10000000)
392
> test(inversed, 10000000)
390

以替代顺序测试时的行为相同。

> test(inversed, 10000000)
25
> test(straight, 10000000)
392
> test(inversed, 10000000)
394

我在 Chrome 浏览器和 Node.js 中都对其进行了测试,但我完全不知道为什么会发生这种情况。 效果会一直持续到我刷新当前页面或重启 Node REPL。

如此显着(大约差 12 倍)性能的原因是什么?

PS。由于它似乎只在某些环境中有效,请编写您用来测试它的环境。

我的是:

操作系统:Ubuntu 14.04 节点 v0.10.37 Chrome 43.0.2357.134(官方版本)(64位)

/编辑 在 Firefox 39 上,无论顺序如何,每次测试都需要大约 5500 毫秒。它似乎只发生在特定引擎上。

/Edit2 将函数内联到测试函数使其始终在同一时间运行。 如果函数参数始终是相同的函数,是否有可能进行内联函数参数的优化?

【问题讨论】:

我猜这与垃圾收集有关。垃圾收集将在收集器出现并清理所有剩余物之前创建已用内存的峰值。如果你改变功能的顺序会有什么不同吗? 尝试切换测试:首先针对inversed 运行,然后针对straight 在 V8 和 Spidermonkey 中得到确认。即使inversedstraight 具有完全相同的定义,也会发生这种情况。可能调用多个函数会产生额外的开销。 @KrzysztofWende 这也是我一直在阅读的内容。我没有发现任何迹象表明堆栈内存会被 bc 清除,我也看不出这会很有用的直接原因。我也不是这方面的专家,但这个问题的答案对于任何程序员来说都可能很有趣。最终可能值得赏金。 也许 fn 是内联的,直到它可以采用多个值。 【参考方案1】:

一旦你用两个不同的函数fn() 调用test,它内部的调用点就变成了超多态,V8 无法内联它。

V8 中的函数调用(相对于方法调用o.m(...))伴随着一个元素内联缓存,而不是真正的多态内联缓存。

由于 V8 无法在 fn() 调用站点内联,因此无法对您的代码应用各种优化。如果您查看IRHydra 中的代码(为了方便您,我上传了编译工件),您会注意到test 的第一个优化版本(当它专门用于fn = straight 时)有一个完全空的主循环。

V8 刚刚内联了 straight删除了您希望使用死代码消除优化进行基准测试的所有代码。在旧版本的 V8 而不是 DCE 上,V8 只会通过 LCM 将代码提升出循环 - 因为代码是完全循环不变的。

straight 未内联时,V8 无法应用这些优化 - 因此性能差异。较新版本的 V8 仍将 DCE 应用于 straightinversed 本身,将它们变成空函数

所以性能差异并不大(大约 2-3 倍)。较旧的 V8 对 DCE 的攻击性不够——这将体现在内联和非内联情况之间的更大差异,因为内联情况的峰值性能完全是激进的循环不变代码运动 (LICM) 的结果。

在相关说明中,这说明了为什么不应该这样编写基准测试 - 因为它们的结果没有任何用处,因为您最终会测量一个空循环。

如果您对多态性及其在 V8 中的含义感兴趣,请查看我的帖子 "What's up with monomorphism"(“并非所有缓存都相同”部分讨论了与函数调用相关的缓存)。我还建议通读我的一篇关于微基准测试危险的演讲,例如来自 GOTO Chicago 2015 (video) 的最新 "Benchmarking JS" 演讲 - 它可能会帮助您避免常见的陷阱。

【讨论】:

“Megamorphic”函数没有被内联是我不知道的一个见解。感谢您让我更加了解引擎中的此类优化(包括“单态”类型)。当函数变得超态,并且不能再应用优化时,这是否意味着全局缓存中没有可放置的内联缓存并且在查找发生时出现未命中? 我不确定我是否理解这个问题。函数不能变成超态的——它是函数内部每个单独操作的属性,例如属性访问o.x 或函数调用f()。当 函数调用 变成超态 V8 时,无法内联调用 - 仅此而已。【参考方案2】:

你误解了堆栈。

虽然“真正的”堆栈确实只有PushPop 操作,但这并不真正适用于用于执行的那种堆栈。除了PushPop,你还可以随机访问任何变量,只要你有它的地址。这意味着本地人的顺序无关紧要,即使编译器没有为您重新排序。在伪汇编中,你似乎认为

var x = 1;
var y = 2;

x = x + 1;
y = y + 1;

翻译成类似的东西

push 1 ; x
push 2 ; y

; get y and save it
pop tmp
; get x and put it in the accumulator
pop a
; add 1 to the accumulator
add a, 1
; store the accumulator back in x
push a
; restore y
push tmp
; ... and add 1 to y

其实真正的代码更像是这样的:

push 1 ; x
push 2 ; y

add [bp], 1
add [bp+4], 1

如果线程堆栈真的是一个真实的、严格的堆栈,这是不可能的,真的。在这种情况下,操作的顺序和本地人将比现在更重要。相反,通过允许随机访问堆栈上的值,您可以为编译器和 CPU 节省大量工作。

要回答您的实际问题,我怀疑这两个函数实际上都没有做任何事情。你只是修改了局部变量,你的函数没有返回任何东西——编译器完全删除函数体,甚至可能是函数调用是完全合法的。如果确实如此,那么您观察到的任何性能差异都可能只是一个测量工件,或者与调用函数/迭代的固有成本有关的东西。

【讨论】:

这更多是对为什么我的测试首先是愚蠢的而不是真正回答实际问题的答案,但无论如何感谢您的澄清;) @KrzysztofWende 是的,我知道我在这里避开了这个问题,但我认为这有助于解释为什么你的测试基本上是无关紧要的。如果您真的只有pushpop,那就太糟糕了 - 真的没有人会使用堆栈。 从数据结构的角度来看,它看起来如何?如果它允许随机顺序而不是真正的堆栈。这些只是随机内存指针在它们适合的地方浮动吗?如果是,为什么它毕竟被称为堆栈? @KrzysztofWende 它仍然有用 - 基本语义仍然存在。当你分配一个新的本地时,你push。当您离开范围时,您 pop all 该范围的本地人。当你调用一个方法时,你push 返回地址和参数。当你返回时,你pop返回地址和参数。但是,出于性能(和简单性)的原因,您可以在任何时候在它们有效的范围内单独处理任何这些参数(例如)。也可以将“引用”传递给更高范围内的本地,以便被调用者可以修改调用者的本地。 @KrzysztofWende 所以是的,它仍然一个堆栈 - 但它也是一个允许随机访问的堆栈。它不允许随机分配或释放 - 这仍然完全是关于 pushpop (公平地说,当你一次丢弃多个堆栈帧时,像“trim”这样的操作)。如果您愿意,可以将其视为增强的堆栈。【参考方案3】:

将函数内联到测试函数使其始终在同一时间运行。 如果函数参数始终是相同的函数,是否有可能进行内联函数参数的优化?

是的,这似乎正是您所观察到的。正如@Luaan 已经提到的,编译器可能会丢弃您的straightinverse 函数的主体,因为它们没有任何副作用,而只是操作一些局部变量。

当您第一次调用test(…, 100000) 时,优化编译器在经过一些迭代后意识到被调用的fn() 始终是相同的,并将其内联,避免了代价高昂的函数调用。它现在所做的只是将变量递减 1000 万次并针对 0 进行测试。

但是当你用不同的fn 调用test 时,它必须去优化。它稍后可能会再次进行一些其他优化,但现在知道有两个不同的函数要调用,它不能再内联它们了。

由于您真正衡量的唯一内容是函数调用,这会导致结果出现严重差异。

查看函数中的局部变量是否存储在堆栈中的实验

关于您的实际问题,不,单个变量不存储在堆栈中 (stack machine),而是存储在寄存器中 (register machine)。在您的函数中声明或使用它们的顺序并不重要。

然而,它们存储在the stack,作为所谓的“堆栈帧”的一部分。每个函数调用都有一帧,存储其执行上下文的变量。在您的情况下,堆栈可能如下所示:

[straight: a, b, c, d, e]
[test: fn, times, i, t]
…

【讨论】:

变量是如何存储的是高度实现特定的问题,尤其是在优化相关函数后 - 因为局部变量可能会简单地分解为无。 @VyacheslavEgorov:是的,我不是字面上的意思。只是从概念上讲,它们是作为堆栈框架一部分的词法环境的一部分,它们存储在实际实现中是完全不同的事情。

以上是关于是啥让这个函数运行得慢得多?的主要内容,如果未能解决你的问题,请参考以下文章

是啥让 PHP 的 mail() 函数这么慢?

几乎相同的代码运行速度要慢得多

pandas 比 numpy 慢得多?

对于非常接近零的值,双重计算运行速度要慢得多

为什么这个通过PHP / FastCGI在IIS中运行的java程序比在shell中慢得多?

为啥在 SQL Azure 上运行查询要慢得多?