在 JavaScript 和 WebAssembly 之間呼叫執行速度終於快了
在 Mozilla,我們希望 WebAssembly 的執行儘可能的快。
這從它的設計有關 - 有很大的吞吐量。然後我們用一個流基線編譯器(streaming baseline compiler)改進載入時間。使用這項技術,我們編譯程式碼的速度比從網路上載入到本地還要快。
那麼,接下來呢?
我們專案重點之一 - 很容易地將 JS 和 WebAssembly 結合起來。但是一直以來兩種語言之間呼叫函式並不是很快。實際上,兩種語言之間的函式呼叫執行速度慢是出了名的,我之前的WebAssembly 系列中講到過。
如你所見,這一切已經發生了變化。
這意味著在 Firefox 最新 beta 版本中, 在 JS 和 WebAssembly 之間函式呼叫比非行內 JS 到 JS 函式的呼叫要快。好哇:tada:

因此目前這些呼叫在 Firefox 很快了。但是,一如既往,我並不僅僅是告訴你這些呼叫很快。我想要解釋我們是如何做到的。因此,讓我們看下是如何在 Firefox 中改進每一種呼叫(嗨還有改進的程度)。
但是,首先我們看下引擎是如何處理這些呼叫的。(如何你已經知道引擎是如何處理函式呼叫的,你可以跳過這一部分)
函式呼叫如何工作的?
函式是 JavaScript 的重點之一。函式可以做很多處理,比如:
- 函式作用域內的變數賦值(被稱為本地變數)
- 使用瀏覽器內建的函式,像 Math.random
- 呼叫在程式碼中定義的函式
- 返回一個值

但是這是如何工作?你編寫的函式是如何讓機器按你的想法執行?
像我之前的第一個 WebAssembly 系列中解釋的那樣,使用像 Javascript 程式語言和計算機理解的語言不一樣。為了執行程式碼,我們下載的 .js 檔案需要轉換為機器能夠理解的機器碼。

每個瀏覽器都有內建的翻譯器。翻譯器有時候被稱為 JavaScript 引擎或者是 JS 執行時。然而,這些引擎現在也能處理 WebAssembly ,這樣的術語可能混淆。在這篇文章中,我們稱它為引擎。
每種瀏覽器都有自己的引擎:
儘管每種引擎不一樣,但它們都適用於同樣的總體思路。
當瀏覽器遇到一些 JavaScipt 程式碼,它會啟動引擎執行程式碼。引擎會用自己的方式,到達需要呼叫的函式直到檔案結束。
假設我們想玩康威的生活遊戲。引擎為我們渲染畫面,但是事實證明並沒有那麼簡單...

因此引擎檢查下一個函式,但是下一個函式將會通過呼叫更多的函式傳送給引擎更多的人物。

引擎繼續到巢狀的任務,知道得到函式的返回。

然後它以相反的方向返回到每一個經過的函式。

如果引擎之前的步驟正確 - 如果給了正確的函式正確的引數,能夠用它自己的方式回到起始函式 - 它需要追蹤一些資訊。
它通過使用一種被稱為棧幀(呼叫幀)的方式實現的。每個函式對應一張表單,其中有函式的引數,還有返回值的地址,並且包含這個函式建立的本地變數。

它通過將這些帶有表單的紙張放在一個棧裡面來追蹤它們。棧頂的紙張是當前函式正在處理的。當處理完一個函式,丟掉函式對應的表單。因為是一個棧,有一張在棧最下面的一張紙。我們需要返回值到這個地方。
這個堆疊被稱為呼叫堆疊

引擎建立這個堆疊。隨著函式呼叫,幀被新增到棧中。隨著函式返回,幀被從棧中移除。一直保持這種變化,直到一切幀從棧中彈出。
這就是函式呼叫基本的工作方式。目前,讓我們看下是為什麼在 JavaScript 和 WebAssembly 之間的函式呼叫如此之慢,並且談下我們是如何在 Firefox 中讓它變的很快。
我們是如何讓 WebAssembly 函式呼叫很快的?
在最新的 Firefox Nightly 版本中,我們優化了呼叫 - JavaScript 到 WebAseembly 和 WebAssembly 到 JavaScript。我們也讓 WebAssembly 到內建函式的呼叫很快。
我們所有做的優化是讓引擎的工作變得簡單。改進可以分為兩部分:
- 減少工作薄計(減少幀的維護) - 意味著擺脫不必要組織棧幀的工作。
- 砍掉中介 - 函式之間呼叫走最直接的路徑
讓我們看下都在哪裡派上了用場。
優化 WebAssembly 到 JavaScript 的呼叫
當引擎經過你的程式碼,它不得不處理有兩種語言編寫的函式 - 即使你的函式都是用 JavaScript 編寫的。
那些執行在直譯器中的程式碼 - 被轉換為位元組碼。這是一種比 JavaScript 跟接近機器碼的一種原始碼,但並不是機器碼。這執行的相當快,但是並沒有達到理想的狀態。
其他的一些函式 - 那些被頻繁呼叫的 - 被JIT(just-in-time)編譯器直接轉換為機器碼。這些被轉換為機器碼的程式碼不會在直譯器中執行。
因此我們有兩種語言編寫的函式;位元組碼和機器碼。
我把這些由兩種語言編寫的函式看作我們遊戲中不同的大洲。

引擎需要能夠在這些大洲之間來回穿梭。但是當它在這些大洲之間來回跳躍的時候,需要一些資訊,比如:另一個大洲的相對位置(它需要跳回來)。引擎也會按需要分離這些幀(引擎也需要在幀與幀之間來回穿梭)。
為了組織工作,引擎會建立一個資料夾,然後把需要的資訊放在旅行的時候的口袋裡 - 例如:它從哪裡進入大陸。
它將會使用口袋去儲存棧幀,口袋會隨著引擎在大洲上產生越來越多的棧幀擴大。

每當它切換到一個新的大洲,引擎會新建一個資料夾。新建一個資料夾唯一的問題是,它必須通過 C++。通過C++ 增加大量成本。
這是一個在我的第一個 WebAssembly 系列中談到的一個蹦床運動。

每次你都必須使用一個蹦床,浪費了時間。
在我們的大洲遊戲比喻中,在每趟旅行兩個大洲之間的蹦床點都有一個強制性的短暫的停留。

那麼,這些是如何讓和 WebAssembly 一起工作時變慢的?
當我們第一次新增 WebAssembly 的支援時,我們有不同型別的資料夾。因此儘管經過 JIT 的程式碼和 WebAssembly 程式碼都被編譯為機器語言,但我們把它們看作不同的語言。我們將它們看作是在分割的大洲上面。

這樣的花費是不必要的,主要體現在兩個方面:
- 它建立了一個不必要的資料夾,和基於此的設定和銷燬
- 它需要通過 C++ 來做蹦床運動(建立資料夾及其他一些設定)
我們通過為 JIT 過的程式碼和 WebAssembly 的程式碼歸納為一個資料夾來修復這個問題。就像是我們將兩個大洲組合在了一起,讓你在這兩個大洲之間切換不需要蹦床。

通過這項技術 WebAssembly 呼叫 JS 的函式幾乎和 JS 呼叫 JS 函式一樣快。

儘管我們也使用了其他的小技術來加速呼叫。
優化 JavaScript » WebAssembly 的函式呼叫
儘管經過 JIT 的 JavaScript 程式碼,和 WebAssembly 說同樣的語言,但它們仍然有不同的習俗。
比如,為了處理動態型別,JavaScript 使用了以一種稱為“裝箱”的操作。
因為 JavaScript 中變數沒有明確的型別,型別需要在執行時確定。引擎通過為值新增一個標誌,來追蹤值的型別。
就好像 JavaScript 在值周圍放了一個箱子,箱子包含那個代表值型別的標誌。例如,末尾的 0 代表整型。

為了計算兩個整數的和,系統需要移除箱子。比如,為變數 a 移除箱子,然後為變數 b 移除箱子。

然後將兩個移除箱子的值加在一起。

然後再在所求值的周圍把箱子加回去,以便系統知道所求結果的型別。

這將你期望的1個操作變成了4個操作.. 即使在某些情況下,你並不需要“裝箱”操作(比如靜態型別的語言),不想讓這成為負擔。
旁註:JavaScript JITS 在很多情況下可以避免這種 "封箱解箱" 的操作,但在一般情況下,比如函式呼叫,需要回到”封箱“操作。
這就是為什麼 WebAssembly 期望”解箱“ 過的引數,和不”封箱“函式返回值。因為 WebAssembly 是靜態型別語言,它沒必要新增這一開銷。WebAssembly 也期望傳的值在特定的地方 - 暫存器,而不是 JavaScript 常用的棧裡面。
如果引擎獲取一個來自 JavaScript 的引數,用箱子封裝一下,並把它給 WebAssembly 函式,WebAssembly 並不知道如何使用它。

因此,在將引數給 WebAssembly 之前,引擎需要”解箱“這個值,然後放在暫存器裡面。
為了執行這個步驟,會再一次使用 C++。儘管我們不需要通過 C++ 將蹦床設定為啟用,仍然需要為傳遞的值做一些準備工作(從 JavaScript 到 WebAssembly)。

來到中間人這裡是一個很大的開銷,尤其是對於那些沒那麼複雜的。因此,減少中間商會更好。
這就是我們做的事情。我們把 C++ 執行的程式碼 - 入口存根,讓它直接被 JIT 程式碼呼叫。入口存根”解箱值“然後放在正確的地方(暫存器)。通過這樣做,我們擺脫了 C++ 的蹦床運動。
我把這看作一個備忘錄,引擎不用去 C++ 就可以使用。相反,當引擎在 WebAssembly 呼叫 JavaScript 函式的時候,會”解箱“值。

因此,讓 JavaScript 到 WebAssebly 的呼叫變得快了。

但在某些情況下,可以更快。事實上,我們可以做到,在某些情況下,JavaScript 到 WebAssembly 的呼叫比 JavaScript 到 JavaScript 的呼叫還快。
更快 JavaScript » WebAssembly: 單一型別的呼叫
當一個 JavaScript 呼叫另一個 JavaScript 函式的時候,它不知道另一個期望什麼樣的引數。因此,預設對傳入的引數做“封箱”操作。
但是,如果 JavaScript 函式知道它每次呼叫的函式每次傳入的引數型別都是一樣的會怎麼樣?JavaScript 函式就可以提前按所期望的方式打包引數。

這是通用 JS JIT 優化的例項之一 - 型別特殊化(type specialization)。 當一個函式特殊化,它能確切知道呼叫的函式期望什麼型別的引數。這意味著它可以提前準備引數...,意味著引擎不再需要備忘單和在“解箱”上面花費額外的開銷。
這種呼叫 - 每次都呼叫同樣的函式 - 被稱為“單一狀態的呼叫”。在 JavaScript 中,對於一個單一狀態的呼叫,你需要每次用相同型別的引數呼叫這個函式。但是 WebAssembly 函式有明確的型別,呼叫程式碼不需要擔心引數型別是否一致 - 它們會用強迫的方式讓你每次都傳相同型別的引數。
如果你能組織你的程式碼始終用相同型別的引數,呼叫 WebAssembly 匯出的函式,那麼你的呼叫將會非常快。實際上,這些呼叫比很多 JavaScript 呼叫 JavaScript 還要快。

未來的工作
這裡有一個例外的情況,優化過的 JavaScript 》》WebAssembly 並不比 JavaScript 》》JavaScript 快。就是 JavaScript 有內聯化函式的時候。
內聯化基本的概念是當你有一個函式一遍又一遍的呼叫相同的函式時,你可以有更大的捷徑。編譯器直接複製一份放在呼叫的地方,而不是讓 引擎去和其他的函式溝通。這意味著引擎每必要到處跑 - 自需要待在原地,執行計算。
我把這看作被呼叫函式將自己的技能教給了呼叫函式。

當一個函式被呼叫多次時,這是一個 JavaScript 引擎所做的優化 - 當它“hot”- 並且當函式呼叫次數相對少的時候。
我們很明確要在未來對內聯化的 WebAssembly 到 JavaScript 中新增支援,並且這也是為什麼兩種語言在一個引擎上工作的很好的原因。這意味著在後臺它們可以使用同意的 JIT 和相同的編譯器中間表示形式,因此它們之間可以進行互動操作,如果它們分割在不同的引擎,互動操作根本不可能。
優化 WebAssembly » 內建函式 呼叫
這裡有一種呼叫比較慢:當 WebAssembly 函式呼叫 JS 內建函式時。
內建函式 是瀏覽器提供的, 像 Math.random
。很容易忘記它們也是可以被呼叫的普通函式。
有時候內建函式是由 JavaScript 本身實現的,這種情況被稱為自託管(self-hosted)。這可以讓它們執行的很快,因為意味著你不需要通過 C++:所有的都是通過 JavaScript 執行的。但是有些函式只是在用 C++ 實現的時候執行的更快。
不同的引擎對於哪些內建函式用 JS 實現,哪些由 c++ 實現有自己的策略。引擎經常混合使用兩種語言編寫內建函式。
在用 JavaScript 編寫的內建函式的情況下,會受益於我們談論的所有的優化。但是使用 C++ 編寫的函式,我們會後退不得不使用蹦床。

這些函式會被呼叫很多次,因此你想要呼叫優化。為了讓呼叫更快些,我們為內建的函式添加了特定的路徑。當你傳遞一個內建的函式到 WebAssembly 中,引擎會發現你傳遞的是一個內建函式,這個時候它會知道如何獲取快速路徑。這意味著你沒必要通過蹦床。
就好像我們建造了一個通往內建大洲的橋。如果你從 WebAssembly 到 內建函式你可以使用那個橋。(旁註:JIT 已經為這種情況做了優化,儘管沒在圖上顯示。)

使用這項技術,呼叫內建函式比原先更快了。

未來的工作
目前唯一支援的僅限於 math 的內建函式。因為 WebAssembly 當前只支援整型和浮點型作為值型別。
math 類的函式工作的很好,因為它們都處理的是數字,但其他內建的函式如 DOM 類的工作並不好。因此當前你想呼叫它們其中一個函式,你不得不使用 JavaScript。這就是wasm-bindgen 做的事情。

但是 WebAssembly 會 馬上在更多型別上面做優化 。當前的提案實驗性支援已經在 FireFox Nightly 版本中了 - 通過 javascript.options.wasm_gc 開啟。一旦這些型別獲得支援,你將能夠直接從 WebAssembly 呼叫,而不用使用 JS。
我們實施的優化 Math 內建函式的基礎設施可以擴充套件到其他的內建函式,這將會保證很多內建函式更快。
仍然有一些內建函式需要通過 JavaScript,例如,呼叫這些內建函式,如果它們使用了 new 關鍵字或者如果它們使用了 getter 或者 setter。剩餘的內建函式將會通過 宿主繫結提案 解決。
結論
這就是我們在 FireFox 中關於 JavaScript 和 WebAssembly 之間呼叫所做的優化,你應該會很快在其他的瀏覽器中看到了。
