模組分割

wasm-split 和 SPLIT_MODULE Emscripten 整合皆在積極開發中,並可能經常變更和新增功能。此頁面將隨時更新最新變更。

大型程式碼庫通常包含許多在實務中很少使用或在應用程式生命週期早期從未使用過的程式碼。載入未使用的程式碼可能會明顯延遲應用程式啟動,因此最好延遲載入這些程式碼,直到應用程式已經啟動後。一個很好的解決方案是使用動態連結,但這需要將應用程式重構為共享程式庫,並且也會帶來一些效能開銷,因此並非總是可行。模組分割是另一種方法,其中模組在正常建置後會分割成單獨的部分,即主要模組和次要模組。主要模組會先載入,並包含啟動應用程式所需的程式碼,而次要模組則包含稍後或根本不需要的程式碼。次要模組會自動依需求載入。

wasm-split 是一個 Binaryen 工具,可執行模組分割。執行 wasm-split 後,主要模組具有與原始模組相同的所有匯入和匯出,並且旨在作為其替代品。但是,它也會為每個分割到次要模組的次要函式匯入一個預留位置函式。在載入次要模組之前,對次要函式的呼叫會改為呼叫適當的預留位置函式。預留位置函式負責載入和實例化次要模組,當實例化時,次要模組會自動以原始次要函式取代所有預留位置函式。載入次要模組後,載入它的預留位置函式也負責呼叫其對應的新載入次要函式,並將結果傳回給其呼叫者。因此,次要模組的載入對主要模組完全透明;它看起來只是函式呼叫花了很長時間才傳回。

目前,分割模組的唯一工作流程是檢測原始模組以收集已執行函式的設定檔,使用一些有趣的工作負載執行已檢測的模組,並使用產生的設定檔來決定如何分割模組。wasm-split 會將在任何設定檔工作負載期間執行的任何函式留在主要模組中,並將所有其他函式分割到次要模組中。

Emscripten 具有與 wasm-split 的原型整合,可透過 -sSPLIT_MODULE 選項啟用。此選項會發出套用 wasm-split 檢測的原始模組,以便準備好收集設定檔。它也會將負責將次要模組載入的預留位置函式插入到發出的 JS 中。然後,開發人員負責執行適當的工作負載、收集設定檔,並使用 wasm-split 工具執行分割。分割模組後,所有項目都會正確運作,而無需進一步變更初始編譯產生的 JS。

基本範例

讓我們演練一個基本範例,了解如何將 SPLIT_MODULE 與 Node 搭配使用。稍後在「在網路上執行」一節中,我們將討論如何調整範例以在網路上執行。

這是我們的應用程式程式碼

// application.c

#include <stdio.h>
#include <emscripten.h>

void foo() {
  printf("foo\n");
}

void bar() {
  printf("bar\n");
}

void unsupported(int i) {
  printf("%d is not supported!\n", i);
}

EM_JS(int, get_number, (), {
  if (typeof prompt === 'undefined') {
    prompt = require('prompt-sync')();
  }
  return parseInt(prompt('Give me 0 or 1: '));
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
}

此應用程式會提示使用者輸入一些輸入,並根據使用者提供的內容執行不同的函式。它會使用 prompt-sync npm 模組,使提示行為在 Node 和網路之間可攜式。我們將看到在分析期間提供的輸入將決定如何在主要模組和次要模組之間分割函式。

我們可以使用 -sSPLIT_MODULE 編譯應用程式

$ emcc application.c -o application.js -sSPLIT_MODULE

除了典型的 application.wasm 和 application.js 檔案之外,這還會產生一個 application.wasm.orig 檔案。application.wasm.orig 是原始、未修改的模組,正常的 Emscripten 建置會產生此模組,而 application.wasm 則已由 wasm-split 檢測以收集設定檔。

檢測的模組有一個額外的匯出函式 __write_profile,它將一個記憶體緩衝區的指標和長度作為參數,它會將設定檔寫入該緩衝區。 __write_profile 會傳回設定檔的長度,並且只有在提供的緩衝區夠大時才會寫入資料。 __write_profile 可以從 JS 外部呼叫,也可以從應用程式本身內部呼叫。為了簡單起見,我們將在這裡的 main 函式末尾呼叫它,但請注意,這表示在 main 之後呼叫的任何函式,例如全域物件的解構函式,都不會包含在設定檔中。

這是寫入設定檔的函式和我們新的 main 函式

EM_JS(void, write_profile, (), {
  var __write_profile = wasmExports.__write_profile;
  if (!__write_profile) {
    return;
  }

  // Get the size of the profile and allocate a buffer for it.
  var len = __write_profile(0, 0);
  var ptr = _malloc(len);

  // Write the profile data to the buffer.
  __write_profile(ptr, len);

  // Write the profile file.
  var profile_data = HEAPU8.subarray(ptr, ptr + len);
  const fs = require("fs");
  fs.writeFileSync('profile.data', profile_data);

  // Free the buffer.
  _free(ptr);
});

int main() {
  int i = get_number();
  if (i == 0) {
    foo();
  } else if (i == 1) {
    bar();
  } else {
    unsupported(i);
  }
  write_profile();
}

請注意,我們只會在 __write_profile 匯出存在時嘗試寫入設定檔。這一點很重要,因為只有檢測的、未分割的模組才會匯出 __write_profile。分割的模組不會包含設定檔檢測或此匯出。

我們新的 write_profile 函式取決於 malloc 和 free 可供 JS 使用,因此我們需要在命令列上明確匯出它們

$ emcc application.c -o application.js -sSPLIT_MODULE -sEXPORTED_FUNCTIONS=_malloc,_free,_main

現在我們可以執行我們的應用程式,它會產生一個 profile.data 檔案。下一步是使用 wasm-split 和設定檔來分割原始模組 application.wasm

$ wasm-split --enable-mutable-globals --export-prefix=% application.wasm.orig -o1 application.wasm -o2 application.deferred.wasm --profile=profile.data

讓我們分解一下所有這些選項的用途。

--enable-mutable-globals

此選項會啟用可變全域目標功能,這允許匯入和匯出可變 Wasm 全域變數(而不是 C/C++ 全域變數)。wasm-split 必須在主要模組和次要模組之間共享可變全域變數,因此它需要啟用此功能。

--export-prefix=%

這是新增到 wasm-split 建立的所有新匯出的前置詞,以將模組元素從主要模組共享到次要模組。前置詞可用於區分「真實」匯出與僅存在於次要模組使用的匯出。Emscripten 的 wasm-split 整合希望特別使用「%」作為前置詞。

-o1 application.wasm

將主要模組寫入 application.wasm。請注意,這會覆寫先前由 Emscripten 產生的已檢測模組,因此應用程式現在將使用分割的模組而不是已檢測的模組。

-o2 application.deferred.wasm

將次要模組寫入 application.deferred.wasm。Emscripten 希望次要模組的名稱與主要模組的名稱相同,並將「.wasm」取代為「.deferred.wasm」。

--profile=profile.data

指示 wasm-split 使用 profile.data 中的設定檔來引導分割。

再次在 node 中執行 application.js,我們可以發現應用程式的工作方式與之前相同,但如果我們執行分析工作負載中未使用的任何程式碼路徑,應用程式將會列印一則有關呼叫預留位置函式和載入延遲模組的控制台訊息。

分析多個工作負載

wasm-split 支援將多個分析工作負載的設定檔合併到單一設定檔中以引導分割。在任何工作負載中執行的任何函式都會保留在主要模組中,而所有其他函式都會分割到次要模組中。

此命令會將任意數量的設定檔(此處僅為 profile1.data 和 profile2.data)合併到單一設定檔中

$ wasm-split --merge-profiles profile1.data profile2.data -o profile.data

多執行緒程式

預設情況下,wasm-split 工具收集的資料會儲存在 Wasm 全域變數中,因此它是執行緒本地的。但在多執行緒程式中,從所有執行緒收集效能分析資訊非常重要。為了達到這個目的,您可以告知 wasm-split 使用 --in-memory 旗標在共享記憶體中收集共享的效能分析資訊。這會使用從位址零開始的記憶體來儲存效能分析資訊,因此您也必須將 -sGLOBAL_BASE=N 傳遞給 Emscripten,其中 N 至少是模組中函數的數量,以防止程式覆蓋該記憶體區域。

分割後,多執行緒應用程式目前會在每個執行緒上分別擷取和編譯次要模組。編譯後的次要模組不會像 Emscripten 將主要模組發送給執行緒那樣,透過 postMessage 發送給每個執行緒。這並不像聽起來那麼糟,因為如果設定了適當的 Cache-Control 標頭,從 Worker 下載次要模組將會從快取中提供服務,但改進這一點是未來的工作方向。

在 Web 上執行

當為 Web 應用程式使用 SPLIT_MODULE 時,要記住的一個複雜之處是,次要模組不能同時以延遲和非同步方式載入,這表示它不能在主瀏覽器執行緒上延遲載入。原因是佔位函數需要對主要模組中的函數完全透明,因此它們在同步載入並呼叫正確的次要函數之前無法返回。

這個限制的一個解決方法是預先載入並實例化次要模組,並確保在主瀏覽器執行緒上實例化之前,不會呼叫任何次要函數。然而,很難確保這一點。另一個解決方法是在主要模組上執行 Asyncify 轉換,以允許佔位函數在等待非同步載入次要模組時返回到 JS 事件迴圈。這在 wasm-split 的路線圖上,儘管我們還不知道這個解決方案的大小和效能開銷會是多少。

這種對延遲載入的限制意味著,使用 SPLIT_MODULE 執行應用程式的最佳方式是在 Worker 執行緒中,例如使用 -sPROXY_TO_PTHREAD。在 PROXY_TO_PTHREAD 模式下,除了應用程式主執行緒之外,為瀏覽器主執行緒收集效能分析資訊也很重要,因為瀏覽器主執行緒會執行一些在應用程式主執行緒中沒有執行的函數,例如包裝代理主函數的 shim 和處理代理回瀏覽器主執行緒的函數。有關如何從多個執行緒收集效能分析資訊,請參閱上一節。

另一個小問題是,無法立即從瀏覽器內部將效能分析資料寫入檔案。相反地,必須透過其他方式將資料傳輸到開發人員的機器,例如將其發送到開發伺服器或從控制台複製其 base64 編碼。

以下是實作 base64 解決方案的程式碼

var profile_data = HEAPU8.subarray(ptr, ptr + len);
var binary = '';
for (var i = 0; i < profile_data.length; i++) {
    binary += String.fromCharCode(profile_data[i]);
}
console.log("===BEGIN===");
console.log(window.btoa(binary));
console.log("===END===");

然後,可以透過執行以下命令來建立效能分析檔案

$ echo [pasted base64] | base64 --decode > profile.data

$ base64 --decode [base64 file] > profile.data

與動態連結一起使用

模組分割可以與動態連結結合使用,但要使這兩個功能正確地協同工作,需要開發人員的一些干預。wasm-split 通常需要增加表格以騰出空間給佔位函數,但這表示經過檢測和分割的模組將具有不同的表格大小。通常這不是問題,但是 MAIN_MODULE/SIDE_MODULE 動態連結支援目前需要將表格大小烘焙到 JS Emscripten 發出的程式碼中,因此表格大小需要是穩定的。

為了確保檢測的模組和分割的模組之間的表格大小相同,請使用 -sINITIAL_TABLE=N Emscripten 設定,其中 N 是所需的表格大小。然後,在使用 wasm-split 執行分割時,將 --initial-table=N 傳遞給 wasm-split,以確保分割的模組也具有正確的表格大小。

如果指定的表格大小太小,則在分割後載入主要模組時會收到錯誤訊息。調整您指定的表格大小,直到它足夠大為止。除了在執行時佔用額外的空間外,指定大於必要的表格大小沒有任何缺點。

次要模組的自訂載入

可以透過實作「loadSplitModule」自訂掛鉤函數來覆寫延遲載入次要模組的預設邏輯。該掛鉤是從佔位函數呼叫的,並負責傳回次要模組的 [實例, 模組] 配對。該掛鉤將要載入的檔案名稱(例如「my_program.deferred.wasm」)、用於實例化模組的 import 物件,以及對應於被呼叫佔位函數的屬性作為參數。以下是一個範例實作,它執行與預設實作相同的操作,但增加了一些額外的記錄

Module["loadSplitModule"] = function(deferred, imports, prop) {
    console.log('Custom handler for loading split module.');
    console.log('Called with placeholder ', prop);

    return instantiateSync(deferred, imports);
}

如果預先載入了模組,則此掛鉤可以簡單地實例化模組,而不是像預設實作那樣進行擷取和編譯。然而,如果也預先實例化了預先載入的模組,則會修補掉佔位函數,並且根本不會呼叫它們,因此也不會呼叫這個自訂掛鉤。

當預先實例化次要模組時,import 物件應為

{'primary': wasmExports}

偵錯

wasm-split 有幾個選項可以更輕鬆地偵錯分割的模組。

-v

在分割時,列印主要和次要函數。在合併效能分析時,列印未促成合併效能分析的效能分析。

-g

在主要和次要模組中保留名稱。如果沒有此選項,wasm-split 會改為移除名稱。

--emit-module-names

產生並發出模組名稱,以區分堆疊追蹤中的主要模組和次要模組,即使未使用 -g 也是如此。

--symbolmap

為主要和次要模組發出單獨的對應檔案,將函數索引對應到函數名稱。當與 –emit-module-names 結合使用時,這些對應可用於重新符號化堆疊追蹤。為了確保函數名稱可供 wasm-split 發出到對應中,請將 –profiling-funcs 傳遞給 Emscripten。

--placeholdermap

發出一個對應檔案,將佔位函數索引對應到其對應的次要函數。這對於找出哪個函數導致載入次要模組非常有用。

即將發生的變更

尚未納入本文檔的變更和新功能清單。

計劃與 Asyncify 工具進行整合,這將允許在主瀏覽器執行緒上非同步載入次要模組。