亚洲在线久爱草,狠狠天天香蕉网,天天搞日日干久草,伊人亚洲日本欧美

為了賬號安全,請及時綁定郵箱和手機立即綁定
已解決430363個問題,去搜搜看,總會有你想問的

為什么添加立即調用的 lambda 會使我的 JavaScript 代碼速度提高 2 倍?

為什么添加立即調用的 lambda 會使我的 JavaScript 代碼速度提高 2 倍?

侃侃爾雅 2023-09-14 18:05:07
我正在將一種語言的編譯器優化為 JavaScript,并發現了一個非常有趣(如果不是令人沮喪)的案例:function add(n,m) {  return n === 0 ? m : add(n - 1, m) + 1;};var s = 0;for (var i = 0; i < 100000; ++i) {  s += add(4000, 4000);}console.log(s);它需要2.3s在我的機器上完成[1]。但如果我做一個很小的改變:function add(n,m) {  return (() => n === 0 ? m : add(n - 1, m) + 1)();};var s = 0;for (var i = 0; i < 100000; ++i) {  s += add(4000, 4000);}console.log(s);它完成于1.1s. (() => ...)()請注意,唯一的區別是在 的返回周圍添加了立即調用的 lambda add。為什么這個添加的調用使我的程序速度提高了一倍?[1] MacBook Pro 13 英寸 2020,2.3 GHz 四核 Intel Core i7,Node.js v15.3.0
查看完整描述

1 回答

?
慕的地6264312

TA貢獻1817條經驗 獲得超6個贊

有趣的!從代碼來看,很明顯 IIFE 包裝的版本應該更慢,而不是更快:在每次循環迭代中,它都會創建一個新的函數對象并調用它(優化編譯器最終會避免這種情況,但這并不會)不會立即啟動),所以通常只是做更多的工作,這應該花費更多的時間。

本例中的解釋是內聯。

一點背景知識:將一個函數內聯到另一個函數中(而不是調用它)是優化編譯器為了實現更好的性能而執行的標準技巧之一。不過,它是一把雙刃劍:從好的方面來說,它避免了調用開銷,并且通??梢詫崿F進一步的優化,例如恒定傳播或消除重復計算(請參閱下面的示例)。不利的一面是,它會導致編譯時間更長(因為編譯器做了更多工作),并且會導致生成更多代碼并將其存儲在內存中(因為內聯函數實際上會重復它),并且在像 JavaScript 這樣的動態語言中,優化的代碼通常依賴于受保護的假設,

一般來說,做出完美的內聯決策(不要太多,也不要太少)需要預測未來:提前知道代碼執行的頻率和參數。當然,這是不可能的,因此優化編譯器使用各種規則/“啟發式”來猜測什么可能是一個相當好的決定。

V8 當前的一項規則是:不要內聯遞歸調用。

這就是為什么在代碼的簡單版本中,add不會內聯到自身中。IIFE 版本本質上有兩個相互調用的函數,這被稱為“相互遞歸”——事實證明,這個簡單的技巧足以欺騙 V8 的優化編譯器并使其回避“不要內聯遞歸調用”規則。相反,它愉快地將未命名的 lambda 內聯到add,然后add內聯到未命名的 lambda 中,依此類推,直到大約 30 輪后其內聯預算用完。(旁注:“內聯多少”是有點復雜的啟發法之一,特別是考慮到函數大小,因此我們在這里看到的任何特定行為確實是針對這種情況的。)

在這種特定場景中,所涉及的函數非常小,內聯很有幫助,因為它避免了調用開銷。因此,在這種情況下,內聯提供了更好的性能,即使它是遞歸內聯的(偽裝的)情況,這通常通常對性能不利。它確實是有代價的:在簡單版本中,優化編譯器只花費 3 毫秒進行編譯add,為其生成 562 字節的優化代碼。在 IIFE 版本中,編譯器花費 30 毫秒并生成 4318 字節的優化代碼add。這就是為什么它不像“V8 應該總是內聯更多”那么簡單的原因之一:編譯的時間和電池消耗很重要,內存消耗也很重要,以及簡單的 10 行代碼中可接受的成本(并顯著提高性能)在 100,000 行應用程序中,演示很可能會產生不可接受的成本(甚至可能會影響整體性能)。


現在,了解了發生了什么之后,我們可以回到“IIFE 有開銷”的直覺,并制作一個更快的版本:

function add(n,m) {

  return add_inner(n, m);

};

function add_inner(n, m) {

  return n === 0 ? m : add(n - 1, m) + 1;

}

在我的機器上,我看到:

  • 簡單版本:1650 毫秒

  • IIFE 版本:720 毫秒

  • add_inner 版本:460 毫秒

當然,如果您add(n, m)簡單地實現為return n + m,那么它會在 2 毫秒內終止——算法優化勝過優化編譯器可能完成的任何事情:-)


附錄:優化好處的示例??紤]這兩個函數:

function Process(x) {

  return (x ** 2) + InternalDetail(x, 0, 2);

}


function InternalDetail(x, offset, power) {

  return (x + offset) ** power;

}

(顯然,這是愚蠢的代碼;但我們假設它是在實踐中有意義的東西的簡化版本。)
當天真地執行時,會發生以下步驟:

  1. 評價temp1 = (x ** 2)

  2. InternalDetail參數調用x0,2

  3. 評價temp2 = (x + 0)

  4. 評價temp3 = temp2 ** 2

  5. 返回temp3給調用者

  6. 評價temp4 = temp1 + temp3

  7. 返回temp4

如果優化編譯器執行內聯,那么第一步它將得到:

function Process_after_inlining(x) {
  return (x ** 2) + ( (x + 0) ** 2 );
}

它允許兩種簡化:x + 0可以折疊為x,然后x ** 2計算發生兩次,因此可以通過重用第一次的結果來替換第二次:

function Process_with_optimizations(x) {
  let temp1 = x ** 2;
    return temp1 + temp1;
}

因此,與簡單的執行相比,我們從 7 個步驟減少到了 3 個步驟:

  1. 評價temp1 = (x ** 2)

  2. 評價temp2 = temp1 + temp1

  3. 返回temp2

我并不是預測實際性能會從 7 個時間單位變為 3 個時間單位;這只是為了直觀地說明為什么內聯可以幫助減少一定量的計算負載。
腳注:為了說明所有這些東西是多么棘手,請考慮在 JavaScript 中用x + 0just替換x并不總是可能的,即使編譯器知道它x總是一個數字:如果x碰巧是-0,那么添加0到它會將其更改為+0,這很可能是可觀察的程序行為;-)


查看完整回答
反對 回復 2023-09-14
  • 1 回答
  • 0 關注
  • 107 瀏覽
慕課專欄
更多

添加回答

舉報

0/150
提交
取消
微信客服

購課補貼
聯系客服咨詢優惠詳情

幫助反饋 APP下載

慕課網APP
您的移動學習伙伴

公眾號

掃描二維碼
關注慕課網微信公眾號