主页
» 模块拆分
wasm-split 和 SPLIT_MODULE Emscripten 集成都处于积极开发阶段,可能会频繁发生更改并获得新功能。此页面将及时更新最新更改。
大型代码库通常包含大量实际上很少使用或在应用程序生命周期早期从未使用的代码。加载这些未使用的代码会明显延迟应用程序启动,因此最好将加载这些代码推迟到应用程序启动之后。动态链接是对此的一个很好的解决方案,但这需要将应用程序重构为共享库,并且还带来一些性能开销,因此并不总是可行。模块拆分是另一种方法,其中模块在正常构建后被拆分为单独的部分,即主模块和辅助模块。主模块首先加载,并包含启动应用程序所需的代码,而辅助模块包含稍后或根本不需要的代码。辅助模块将在需要时自动加载。
wasm-split 是一个 Binaryen 工具,用于执行模块拆分。在运行 wasm-split 后,主模块与原始模块具有相同的导入和导出,并旨在作为其直接替代。但是,它还为每个拆分到辅助模块中的辅助函数导入一个占位符函数。在加载辅助模块之前,辅助函数的调用将改为调用相应的占位符函数。占位符函数负责加载和实例化辅助模块,该模块在实例化时会自动用原始辅助函数替换所有占位符函数。加载辅助模块后,加载它的占位符函数还负责调用其相应的全新加载的辅助函数并将结果返回给其调用者。因此,辅助模块的加载对于主模块来说是完全透明的;它看起来就像函数调用花费了很长时间才返回。
目前,拆分模块的唯一工作流程涉及对原始模块进行分析,以收集运行函数的配置文件,使用多个有趣的负载运行该分析的模块,并使用生成的配置文件来确定如何拆分模块。wasm-split 会将任何在任何已分析的负载期间运行的函数保留在主模块中,并将所有其他函数拆分到辅助模块中。
Emscripten 与 wasm-split 具有原型集成,该集成由 -sSPLIT_MODULE
选项启用。此选项将发出带有 wasm-split 分析的原始模块,使其准备好收集配置文件。它还将在发出的 JS 中插入负责加载辅助模块的占位符函数。然后,开发人员负责运行适当的负载,收集配置文件,并使用 wasm-split 工具执行拆分。模块拆分后,所有内容都将正常工作,而无需对初始编译生成的 JS 做进一步更改。
让我们逐步介绍在 Node 中使用 SPLIT_MODULE 的基本示例。在“在 Web 上运行”部分,我们将讨论如何将示例改编为在 Web 上运行。
这是我们的应用程序代码
// 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 和 Web 之间可移植。我们将看到,我们在分析期间提供的输入将决定我们的函数如何在主模块和辅助模块之间拆分。
我们可以使用 -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 全局变量中,因此它是线程本地化的。但在多线程程序中,重要的是从所有线程收集配置文件信息。为此,你可以使用 --in-memory
wasm-split 标志告诉 wasm-split 在共享内存中收集共享配置文件信息。这将使用从地址零开始的内存来存储配置文件信息,因此你必须还将 -sGLOBAL_BASE=N
传递给 Emscripten,其中 N
至少是模块中函数的数量,以防止程序破坏该内存区域。
拆分后,多线程应用程序当前将在每个线程上单独获取和编译辅助模块。编译后的辅助模块不会像 Emscripten 将主模块发送到线程那样发送到每个线程。这并不像听起来那样糟糕,因为从工作线程下载辅助模块如果设置了适当的 Cache-Control 标头,将从缓存中获取,但改进这一点是未来工作的一个领域。
使用 SPLIT_MODULE 时需要牢记的一个复杂情况是,辅助模块无法同时延迟加载和异步加载,这意味着它无法在主浏览器线程上延迟加载。原因是占位函数需要对主模块中的函数完全透明,因此它们必须在同步加载并调用正确的辅助函数之前返回。
解决此限制的一个方法是预先加载和实例化辅助模块,并确保在它在主浏览器线程上实例化之前不会调用任何辅助函数。不过,这可能很难确保。另一个解决方法是在主模块上运行 Asyncify 变换,以允许占位函数在等待辅助模块异步加载时返回 JS 事件循环。这在 wasm-split 路线图中,但我们目前还不确定此解决方案的尺寸和性能开销。
这种延迟加载的限制意味着,运行使用 SPLIT_MODULE 的应用程序的最佳方式是在工作线程中,例如使用 -sPROXY_TO_PTHREAD
。在 PROXY_TO_PTHREAD 模式下,除了应用程序主线程外,还必须收集浏览器主线程的概要文件,因为浏览器主线程运行了一些不在应用程序主线程中运行的函数,例如包装代理主函数的垫片函数以及处理代理回主浏览器线程的调用的函数。有关如何从多个线程收集概要文件,请参见上一节。
另一个较小的复杂情况是,无法立即将概要文件数据从浏览器内部写入文件。相反,必须通过其他方式将数据传输到开发人员的机器,例如将其发布到开发服务器或从控制台复制其 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”自定义钩子函数来覆盖延迟加载辅助模块的默认逻辑。钩子从占位函数中调用,并负责返回辅助模块的 [instance, module] 对。钩子将要加载的文件的名称(例如,“my_program.deferred.wasm”)、用于实例化模块的导入对象以及与已调用占位函数相对应的属性作为参数。以下是一个与默认实现执行相同操作但包含一些额外日志记录的示例实现
Module["loadSplitModule"] = function(deferred, imports, prop) {
console.log('Custom handler for loading split module.');
console.log('Called with placeholder ', prop);
return instantiateSync(deferred, imports);
}
如果模块已预先加载,则此钩子可以简单地实例化模块,而不是也获取和编译它。但是,如果预先加载的模块也预先实例化,则占位函数将被修补,并且永远不会调用,因此此自定义钩子也不会被调用。
在预先实例化辅助模块时,导入对象应为
{'primary': wasmExports}
wasm-split 有几个选项,使调试拆分的模块更容易。
-v
在拆分时,打印主函数和辅助函数。在合并概要文件时,打印不包含在合并的概要文件中的概要文件。
-g
在主模块和辅助模块中保留名称。如果没有此选项,wasm-split 将删除名称。
--emit-module-names
生成并发出模块名称以区分堆栈跟踪中的主模块和辅助模块,即使不使用 -g。
--symbolmap
为主模块和辅助模块发出单独的映射文件,将函数索引映射到函数名称。与 –emit-module-names 结合使用时,这些映射可用于重新符号化堆栈跟踪。为了确保函数名称可供 wasm-split 发出到映射中,请将 –profiling-funcs 传递给 Emscripten。
--placeholdermap
发出映射占位函数索引到其对应辅助函数的映射文件。这对于找出导致加载辅助模块的函数很有用。
尚未包含在本文档中的更改和新功能列表。
计划对与 Asyncify 插入的集成进行工作,这将允许在主浏览器线程上异步加载辅助模块。