- 原文地址:How I wrote the world's fastest JavaScript memoization library
- 原文作者:Caio Gondim
- 譯文出自:掘金翻譯計劃
- 譯者:薛定諤的貓
- 校對者:GangsterHyj,sunui
我是如何實現世界上最快的 JavaScript 記憶化的
在本文中,我將詳細介紹如何實現 fast-memoize.js,它是世界上最快的 JavaScript 記憶化(memoization)實現,每秒能進行 50,000,000 次操作。
我們會詳細討論實現的步驟和決策,並且給出程式碼實現和效能測試作為證明。
fast-memoize.js 是開源專案,歡迎大家給我留言和建議。
不久前,我嘗試了 V8 中一些即將釋出的特性,以斐波那契演算法為基礎做了一些基準測試實驗。
實驗之一就是比較斐波那契演算法的記憶化版本和普通實現,結果表明記憶化版本有著巨大的效能優勢。
意識到這一點,我又翻閱了不同的記憶化庫的實現,並比較了它們的效能(因為……呃,為什麼不呢?)。記憶化演算法本身非常簡單,然而我震驚地發現不同實現之間效能差異巨大。
這是什麼原因呢?
在翻閱 lodash 和 underscore 的原始碼時,我發現預設情況下,它們只能記憶化接受一個引數的函式。於是我就很好奇,能否實現一個足夠快並且可以接受多個引數的版本呢?(或許可以開發出 npm 包給全世界的開發者使用呢?)
下文中,我將詳細介紹實現它的步驟,以及實現過程中所做的決策。
理解問題
『記憶化是儲存函式執行結果,而不是每次重新計算的一種技術。』
換句話說,記憶化就是對於函式的快取。 它只適用於確定性演算法,對於相同的輸入總是生成相同的輸出。
為了便於理解和測試,我們把這個問題拆分成幾個小問題。
分解 JavaScript 記憶化問題
我將這個演算法分解為 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
}複製程式碼
為實現快取,我分別嘗試了:
以下是這些實現的效能測試。本地執行,請執行命令 npm run benchmark:cache
。不同版本實現的原始碼可以在專案的 GitHub 頁面找到。
還需要一個序列化器
在引數是非字面量時,這個版本會有問題,因為轉化為字串時並不唯一。
functionfoo(arg) { returnString(arg) }
foo({a: 1}) // => '[object Object]'
foo({b: 'lorem'}) // => '[object Object]'複製程式碼
這就是為什麼我們還需要一個序列化器,用它來生成引數的指紋(唯一標識,譯者注)。它的速度越快越好。
#2 - 序列化器
序列化器基於給定的輸入輸出一個字串。它必須是一個確定性演算法,意味著對相同的輸入,總是給出相同的輸出。
序列化器生成的字串用作快取的key,代表記憶化函式的輸入。
JSON.stringify
是實現它效能最佳的方式,比其它方式的都好 -- 這也很容易理解,因為 JSON.stringify
是原生的。
我嘗試使用 bound JSON.stringify
(bar = foo.bind(null)
,此時 bar.name
為 bound foo
,譯者注),希望通過減少一次變數查詢來提高效能,但很遺憾沒有效果。
想在本地執行,可以執行命令 npm run benchmark:serializer
,實現的具體程式碼可以在專案的 GitHub 頁面找到。
還剩最後一個部分:策略。
#3 - 策略
策略使用了序列化器和快取,將兩者結合起來。對 fast-memoize.js 來說,策略是我花時間最多的部分。即使非常簡單的演算法,每一個版本迭代都有一些效能提升。
以下是我先後嘗試的方式:
- 普通方式 (初始版本)
- 針對單個引數優化
- 引數推斷
- 偏函式
我們來逐個介紹它們。我會以儘量簡化的程式碼,來介紹每種方式背後的想法。如果某些細節我沒有解釋清楚,你想要深入探究一下,可以在專案的 GitHub 頁面中找到每個版本的程式碼。
本地執行,請執行命令 npm run benchmark:strategy
。
普通方式
這是我第一次嘗試,也是最簡單的版本。步驟是:
- 序列化引數
- 檢查給定輸入的輸出是否已經計算過
- 如果
true
,從快取中讀取結果 - 如果
false
,計算,並且將結果儲存到快取中
使用第一個版本,我們可以達到每秒 650,000 次操作。這個版本是後面優化版本的基礎。
針對單個引數優化
改善效能的一個有效方法是優化熱路徑(hot path,指執行頻率最高的路徑,譯者注)。對我們的程式碼來說,熱路徑就是接受一個基本型別引數的函式,這種情況下我們不需要對引數序列化。
- 檢查
arguments.length === 1
&& 引數為基本型別 - 如果
是
,無需序列化引數,因為基本型別本身就可以作為快取的key - 檢查給定輸入的輸出是否已經計算過
- 如果
true
,從快取中讀取結果 - 如果
false
,計算,並且將結果儲存到快取中
通過避免執行不必要的序列化操作,我們可以得到更快的執行結果(對熱路徑而言)。現在可以達到每秒 5,500,000 次了。
引數推斷
function.length
返回一個已定義函式的形參個數,我們可以利用這個性質避免動態檢查函式的實參個數(即避免 arguments.length === 1
的條件判斷,譯者注),併為單引數函式和非單引數函式分別提供不同的策略。
functionfoo(a, b) {
return a + b
}
foo.length // => 2複製程式碼
省去了這一次條件判斷,我們(的實現)效能又有了一點提升,可以達到每秒 6,000,000 次操作。
偏函式(Partial application)
我覺得大多數時間都花費在了變數查詢上(但沒有量化資料支援),起初我也沒有好的想法去改善。靈機一動,我突然想到可以使用 bind
方法,通過偏函式應用的方法將變數注入到函式中。
functionsum(a, b) {
return a + b
}
const sumBy2 = sum.bind(null, 2)
sumBy2(3) // => 5複製程式碼
這種方式可以將函式的某些引數固定下來。我用就它把原函式,快取,和序列化器固定下來。就用它來試試吧!
哇!效果非常好。我不知道如何進一步改進,但我對這個版本的測試結果已經很滿意了。這個版本可以達到每秒 20,000,000 次操作。
最快的 JavaScript 記憶化組合
上面我們把記憶化分解為了 3 個部分。
對每個部分,我們將其中 2 個部分固定,更換其餘一個測試其效能。通過這種單變數測試,我們能更加確信每次改變的效果--由於 GC 造成的不確定性停頓,JS程式碼的效能並不完全確定。
V8 會更根據函式的呼叫頻率、程式碼結構等因素,做很多執行時優化。
為了確保我們將這 3 部分組合起來時不會錯過大量效能優化的機會,我們嘗試所有可能的組合。
一共 4 種策略 x 2 種序列化器 x 4 種快取 = 32 種不同的組合。本地執行,請執行命令 npm run benchmark:combination
。下面是效能最好的 5 種組合:
圖例:
- 策略: 偏函式, 快取: 普通物件, 序列化器: json-stringify
- 策略: 偏函式, 快取: 無原型物件, 序列化器: json-stringify
- 策略: 偏函式, 快取: 無原型物件, 序列化器: json-stringify-binded
- 策略: 偏函式, 快取: 普通物件, 序列化器: json-stringify-binded
- 策略: 偏函式, 快取: Map, 序列化器: json-stringify
事實證明我們上面的分析是對的。最快的組合是:
- 策略: 偏函式
- 快取: 普通物件
- 序列化器: JSON.stringify
與流行庫的效能對比
有了上面的演算法,是時候把它同最流行的庫做一個效能上的比較了。本地執行,請執行命令 npm run benchmark
。結果如下:
fast-memoize.js是最快的,幾乎是第二名的 3 倍,每秒 27,000,000次操作。
面向未來
V8有一個很新的、未釋出的優化編譯器 TurboFan。
我們現在就應該用它測試一下,因為 TurboFan(極有可能)很快就會新增到 V8 中。通過給 Node.js 設定 flag --turbo-fan
就可以啟用它。本地執行,請執行命令npm run benchmark:turbo-fan
。以下是啟用後的測試結果:
效能幾乎翻倍,現在達到接近每秒 50,000,000 次。
看起來最新的 TurboFan 編譯器可以極大的優化我們最終版本的 fast-memoize.js。
結論
以上就是我建立這個世界上最快的記憶化庫的過程。分別實現各個部分,組合它們,然後統計每種組合方案的效能資料,從中選擇最優的方案。(使用 benchmark.js )。
希望這個過程對其他開發者有所幫助。
fast-memoize.js 是目前最好的 #JavaScrip 庫, 並且我會努力讓它一直是最好的。
並非是因為我聰明絕頂, 而是我會一直維護它。 歡迎給我提交 Pull requests。
正如前 V8 工程師 Vyacheslav Egorov 所言,在虛擬機器上測試演算法效能非常棘手。如果你發現測試中的錯誤,請在 GitHub 上提交 issue。
這個庫也一樣,如果你發現任何問題請提交 issue(如果帶上錯誤用例我會很感激)。帶有改進建議的 Pull Requests 我將感激不盡。
如果你喜歡這個庫,歡迎 star。這是對我們開源開發者的鼓勵哦。
參考文獻
- JavaScript & Hashtable
- Firing up ignition interpreter
- Big-O cheat sheet
- GOTO 2015 • Benchmarking JavaScript • Vyacheslav Egorov
有任何問題,歡迎評論!
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。