[譯] 我是如何實現世界上最快的 JavaScript 記憶化的

薛定諤的貓發表於2017-05-10

我是如何實現世界上最快的 JavaScript 記憶化的

在本文中,我將詳細介紹如何實現 fast-memoize.js,它是世界上最快的 JavaScript 記憶化(memoization)實現,每秒能進行 50,000,000 次操作。
我們會詳細討論實現的步驟和決策,並且給出程式碼實現和效能測試作為證明。

fast-memoize.js 是開源專案,歡迎大家給我留言和建議。

不久前,我嘗試了 V8 中一些即將釋出的特性,以斐波那契演算法為基礎做了一些基準測試實驗。
實驗之一就是比較斐波那契演算法的記憶化版本和普通實現,結果表明記憶化版本有著巨大的效能優勢。

意識到這一點,我又翻閱了不同的記憶化庫的實現,並比較了它們的效能(因為……呃,為什麼不呢?)。記憶化演算法本身非常簡單,然而我震驚地發現不同實現之間效能差異巨大。

這是什麼原因呢?

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
常見 JavaScript 記憶化庫的效能

在翻閱 lodashunderscore 的原始碼時,我發現預設情況下,它們只能記憶化接受一個引數的函式。於是我就很好奇,能否實現一個足夠快並且可以接受多個引數的版本呢?(或許可以開發出 npm 包給全世界的開發者使用呢?)

下文中,我將詳細介紹實現它的步驟,以及實現過程中所做的決策。

理解問題

引自 Haskell 語言 wiki

『記憶化是儲存函式執行結果,而不是每次重新計算的一種技術。』

換句話說,記憶化就是對於函式的快取。 它只適用於確定性演算法,對於相同的輸入總是生成相同的輸出。

為了便於理解和測試,我們把這個問題拆分成幾個小問題。

分解 JavaScript 記憶化問題

我將這個演算法分解為 3 個小問題:

  1. 快取:儲存上一次計算結果。
  2. 序列化:輸入為引數,輸出一個字串用於表示相應的輸入。可以將它視作引數的唯一標識。
  3. 策略:將快取和序列化組合起來,輸出記憶化函式。

現在我們就要分別以不同的方式實現這 3 個部分,測試它們的效能,選擇其中最快的方式,最後將它們結合起來就是我們最終的演算法了。
這樣做的目標就是讓計算機為我們解除重擔!

#1 - 快取

如前文所述,快取儲存了之前的計算結果。

介面

為了抽象實現細節,我們需要建立一個類似於 Map 的介面:

  • has(key)
  • get(key)
  • set(key, value)
  • delete(key)

通過(定義介面)這種方式,只要我們實現了這個介面,就可以修改快取內部的實現,而不影響外部使用。

實現

每次執行記憶化函式,我們需要做的就是:檢查對應輸入的輸出是否已經被計算過。

因此最合理的資料結構是雜湊表。它能夠在 O(1) 時間複雜度檢查某個值是否存在。 從底層看,一個 JavaScript 物件就是一個雜湊表(或類似的結構),所以我們可以將輸入作為雜湊表的 key,將輸出作為它的 value。

    // Keys 代表斐波那契函式的輸入
    // Values 代表函式執行結果
    const cache = {
      5: 5,
      6: 8,
      7: 13
    }複製程式碼

為實現快取,我分別嘗試了:

  1. 普通物件
  2. 無原型物件(避免原型屬性查詢)
  3. lru-cache
  4. Map

以下是這些實現的效能測試。本地執行,請執行命令 npm run benchmark:cache。不同版本實現的原始碼可以在專案的 GitHub 頁面找到。

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
Variable JavaScript memoization cache

還需要一個序列化器

在引數是非字面量時,這個版本會有問題,因為轉化為字串時並不唯一。

    functionfoo(arg) { returnString(arg) }

    foo({a: 1}) // => '[object Object]'
    foo({b: 'lorem'}) // => '[object Object]'複製程式碼

這就是為什麼我們還需要一個序列化器,用它來生成引數的指紋(唯一標識,譯者注)。它的速度越快越好。

#2 - 序列化器

序列化器基於給定的輸入輸出一個字串。它必須是一個確定性演算法,意味著對相同的輸入,總是給出相同的輸出。

序列化器生成的字串用作快取的key,代表記憶化函式的輸入。

JSON.stringify 是實現它效能最佳的方式,比其它方式的都好 -- 這也很容易理解,因為 JSON.stringify 是原生的。
我嘗試使用 bound JSON.stringifybar = foo.bind(null),此時 bar.namebound foo,譯者注),希望通過減少一次變數查詢來提高效能,但很遺憾沒有效果。

想在本地執行,可以執行命令 npm run benchmark:serializer,實現的具體程式碼可以在專案的 GitHub 頁面找到。

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
變數序列化器

還剩最後一個部分:策略

#3 - 策略

策略使用了序列化器快取,將兩者結合起來。對 fast-memoize.js 來說,策略是我花時間最多的部分。即使非常簡單的演算法,每一個版本迭代都有一些效能提升。
以下是我先後嘗試的方式:

  1. 普通方式 (初始版本)
  2. 針對單個引數優化
  3. 引數推斷
  4. 偏函式

我們來逐個介紹它們。我會以儘量簡化的程式碼,來介紹每種方式背後的想法。如果某些細節我沒有解釋清楚,你想要深入探究一下,可以在專案的 GitHub 頁面中找到每個版本的程式碼。

本地執行,請執行命令 npm run benchmark:strategy

普通方式

這是我第一次嘗試,也是最簡單的版本。步驟是:

  1. 序列化引數
  2. 檢查給定輸入的輸出是否已經計算過
  3. 如果 true,從快取中讀取結果
  4. 如果 false,計算,並且將結果儲存到快取中

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
Variable strategy

使用第一個版本,我們可以達到每秒 650,000 次操作。這個版本是後面優化版本的基礎。

針對單個引數優化

改善效能的一個有效方法是優化熱路徑(hot path,指執行頻率最高的路徑,譯者注)。對我們的程式碼來說,熱路徑就是接受一個基本型別引數的函式,這種情況下我們不需要對引數序列化。

  1. 檢查 arguments.length === 1 && 引數為基本型別
  2. 如果,無需序列化引數,因為基本型別本身就可以作為快取的key
  3. 檢查給定輸入的輸出是否已經計算過
  4. 如果 true,從快取中讀取結果
  5. 如果 false,計算,並且將結果儲存到快取中

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
針對單個引數優化

通過避免執行不必要的序列化操作,我們可以得到更快的執行結果(對熱路徑而言)。現在可以達到每秒 5,500,000 次了。

引數推斷

function.length 返回一個已定義函式的形參個數,我們可以利用這個性質避免動態檢查函式的實參個數(即避免 arguments.length === 1 的條件判斷,譯者注),併為單引數函式和非單引數函式分別提供不同的策略。

    functionfoo(a, b) {
      return a + b
    }
    foo.length // => 2複製程式碼

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
引數推斷

省去了這一次條件判斷,我們(的實現)效能又有了一點提升,可以達到每秒 6,000,000 次操作

偏函式(Partial application)

我覺得大多數時間都花費在了變數查詢上(但沒有量化資料支援),起初我也沒有好的想法去改善。靈機一動,我突然想到可以使用 bind 方法,通過偏函式應用的方法將變數注入到函式中。

    functionsum(a, b) {
      return a + b
    }
    const sumBy2 = sum.bind(null, 2)
    sumBy2(3) // => 5複製程式碼

這種方式可以將函式的某些引數固定下來。我用就它把原函式快取,和序列化器固定下來。就用它來試試吧!

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
偏函式

哇!效果非常好。我不知道如何進一步改進,但我對這個版本的測試結果已經很滿意了。這個版本可以達到每秒 20,000,000 次操作

最快的 JavaScript 記憶化組合

上面我們把記憶化分解為了 3 個部分。

對每個部分,我們將其中 2 個部分固定,更換其餘一個測試其效能。通過這種單變數測試,我們能更加確信每次改變的效果--由於 GC 造成的不確定性停頓,JS程式碼的效能並不完全確定。

V8 會更根據函式的呼叫頻率、程式碼結構等因素,做很多執行時優化。

為了確保我們將這 3 部分組合起來時不會錯過大量效能優化的機會,我們嘗試所有可能的組合。
一共 4 種策略 x 2 種序列化器 x 4 種快取 = 32 種不同的組合。本地執行,請執行命令 npm run benchmark:combination。下面是效能最好的 5 種組合:

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
fastest javascript memoize combinations

圖例:

  1. 策略: 偏函式, 快取: 普通物件, 序列化器: json-stringify
  2. 策略: 偏函式, 快取: 無原型物件, 序列化器: json-stringify
  3. 策略: 偏函式, 快取: 無原型物件, 序列化器: json-stringify-binded
  4. 策略: 偏函式, 快取: 普通物件, 序列化器: json-stringify-binded
  5. 策略: 偏函式, 快取: Map, 序列化器: json-stringify

事實證明我們上面的分析是對的。最快的組合是:

  • 策略: 偏函式
  • 快取: 普通物件
  • 序列化器: JSON.stringify

與流行庫的效能對比

有了上面的演算法,是時候把它同最流行的庫做一個效能上的比較了。本地執行,請執行命令 npm run benchmark。結果如下:

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
與流行庫的效能對比

fast-memoize.js是最快的,幾乎是第二名的 3 倍,每秒 27,000,000次操作

面向未來

V8有一個很新的、未釋出的優化編譯器 TurboFan
我們現在就應該用它測試一下,因為 TurboFan(極有可能)很快就會新增到 V8 中。通過給 Node.js 設定 flag --turbo-fan 就可以啟用它。本地執行,請執行命令npm run benchmark:turbo-fan。以下是啟用後的測試結果:

[譯] 我是如何實現世界上最快的 JavaScript 記憶化的
使用 TurboFan 的效能

效能幾乎翻倍,現在達到接近每秒 50,000,000 次

看起來最新的 TurboFan 編譯器可以極大的優化我們最終版本的 fast-memoize.js

結論

以上就是我建立這個世界上最快的記憶化庫的過程。分別實現各個部分,組合它們,然後統計每種組合方案的效能資料,從中選擇最優的方案。(使用 benchmark.js )。
希望這個過程對其他開發者有所幫助。

fast-memoize.js 是目前最好的 #JavaScrip 庫, 並且我會努力讓它一直是最好的。

並非是因為我聰明絕頂, 而是我會一直維護它。 歡迎給我提交 Pull requests

正如前 V8 工程師 Vyacheslav Egorov 所言,在虛擬機器上測試演算法效能非常棘手。如果你發現測試中的錯誤,請在 GitHub 上提交 issue。

這個庫也一樣,如果你發現任何問題請提交 issue(如果帶上錯誤用例我會很感激)。帶有改進建議的 Pull Requests 我將感激不盡。

如果你喜歡這個庫,歡迎 star。這是對我們開源開發者的鼓勵哦。

參考文獻

有任何問題,歡迎評論!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章