优化 WebAssembly 多线程应用:使用 WasmFS 和 mimalloc 实现性能提升

作者:林语者 分类:工程代码

优化 WebAssembly 多线程应用:使用 WasmFS 和 mimalloc 实现性能提升

发布日期:2025年1月30日

大多数 WebAssembly 应用程序与原生应用一样,都能受益于多线程处理。通过使用多个线程,可以实现更多并行处理,并将密集型任务从主线程转移,从而避免延迟问题。

然而,这类多线程应用在内存分配和 I/O 操作方面经常遇到挑战。幸运的是,Emscripten 近期推出的功能为这些问题的解决提供了有力支持。本文将介绍如何通过这些功能在某些场景下实现超过10倍的性能提升。

多线程扩展性

下图展示了在纯数学计算负载下,多线程的高效扩展表现(数据来源于本文使用的基准测试):

![多线程扩展性能图]

该测试专注于纯计算任务,由于每个 CPU 核心都能独立执行,因此随着核心数量增加,性能得到显著提升。这种下降趋势的性能曲线是良好扩展性的典型表现。

尽管使用了 Web Workers 作为并行处理的基础,并且运行的是 Wasm 而非真正的原生代码,但结果表明,Web 平台在执行多线程原生代码方面表现非常出色。

堆内存管理:malloc/free

mallocfree 是所有线性内存语言(如 C、C++、Rust、Zig 等)中管理动态内存和栈内存的关键标准库函数。Emscripten 默认使用 dlmalloc,这是一个紧凑且高效的实现(还支持更紧凑但有时较慢的 emmalloc)。

然而,dlmalloc 在多线程环境下的性能存在限制,因为每次 malloc/free 操作都需要获取锁(由于存在单一的全局分配器)。当多个线程同时进行大量分配时,会产生竞争条件,导致性能下降。

在高频使用 malloc 的基准测试中,我们观察到以下情况:

![dlmalloc 性能图]

即使增加核心数量,性能也没有提升,反而由于线程长时间等待 malloc 锁而恶化。虽然这是最坏情况,但在实际工作负载中,如果分配操作足够频繁,这种情况确实会发生。

mimalloc 内存分配器

dlmalloc 存在专门针对多线程优化的版本(如 ptmalloc3),它们通过为每个线程实现独立的分配器实例来避免竞争。此外,还有其他针对多线程优化的分配器,如 jemalloctcmalloc

Emscripten 选择重点关注近期的 mimalloc 项目,这是微软设计的一款优秀分配器,具有良好的可移植性和性能表现。使用方法如下:

emcc -sMALLOC=mimalloc

使用 mimalloc 后的 malloc 基准测试结果如下:

![mimalloc 性能图]

现在性能实现了高效扩展,随着核心数量增加,速度明显提升。

对比前两个图中的单核性能数据,dlmalloc 耗时 2,660 毫秒,而 mimalloc 仅需 1,466 毫秒,性能提升了近两倍。这表明即使是单线程应用,也能受益于 mimalloc 的高级优化。但需要注意的是,这会带来代码大小和内存使用量增加的代价(这也是 dlmalloc 保持默认状态的原因)。

文件与 I/O 操作

许多应用因各种原因需要使用文件,例如加载游戏关卡或图像编辑器的字体。即使是 printf 等操作,在内部也会使用文件系统将数据写入标准输出。

在单线程应用中,这通常不是问题。如果仅需要 printf,Emscripten 会自动避免链接完整的文件系统支持。然而,在多线程环境下,文件系统访问变得复杂,因为文件操作需要在线程间同步。

Emscripten 的原始文件系统实现(由于使用 JavaScript 实现而被称为 “JS FS”)采用了一个简单模型:仅在主线程中实现文件系统。每当其他线程访问文件时,都需要将请求代理到主线程,这意味着其他线程会在跨线程请求中被阻塞,最终由主线程处理。

这种简单模型在主线程是唯一文件访问者时效果最佳,这是一种常见模式。但当其他线程进行读写操作时,问题就会出现: - 主线程需要处理其他线程的工作,导致用户可见的延迟 - 后台线程需要等待主线程空闲才能执行必要操作,造成处理延迟 - 如果主线程同时等待该工作线程,甚至可能发生死锁

WasmFS 文件系统

为解决这些问题,Emscripten 引入了新的文件系统实现:WasmFS。与用 JavaScript 编写的原始文件系统不同,WasmFS 使用 C++ 编写并编译为 Wasm。

WasmFS 通过将所有文件存储在 Wasm 线性内存中(在线程间共享),最大限度地减少了开销,并支持多线程文件系统访问。现在所有线程都能以相同的性能执行文件 I/O 操作,并且在多数情况下避免了线程间的阻塞。

简单的文件系统基准测试显示了 WasmFS 相较于旧版 JS FS 的显著优势:

![WasmFS 性能对比图]

图表比较了在主线程直接执行文件系统代码与在单个 pthread 中执行的情况。在旧的 JS FS 中,所有文件系统操作都需要代理到主线程,导致 pthread 中的性能下降超过一个数量级。这是因为 JS FS 不是直接进行字节读写,而是涉及锁、队列和等待的线程间通信。

相比之下,WasmFS 允许从任意线程平等访问文件,因此从图表中可以看出主线程和 pthread 之间没有实质性差异。最终,在 pthread 场景下,WasmFS 比 JS FS 快 32 倍。

即使在主线程中,WasmFS 也有明显优势,速度快 2 倍。这是因为 JS FS 每次文件系统操作都需要调用 JavaScript,而 WasmFS 避免了这种情况。WasmFS 只在必要时(如使用 Web API 时)才使用 JavaScript,因此大多数 WasmFS 文件操作都在 Wasm 内部完成。

此外,即使需要 JavaScript,WasmFS 也能使用辅助线程而非主线程,从而避免用户可见的延迟。因此,即使应用程序不是多线程的(或者是多线程但仅在主线程中使用文件),使用 WasmFS 也能获得速度提升。

WasmFS 的使用方法如下:

emcc -sWASMFS

WasmFS 已在生产环境中使用,被认为是稳定的,但尚未支持旧版 JS FS 的所有功能。同时,它包含了一些重要的新功能,如对源私有文件系统(OPFS,强烈推荐用于持久存储)的支持。除非您需要尚未移植的功能,否则 Emscripten 团队建议使用 WasmFS。

总结

对于进行大量内存分配的多线程应用程序或使用文件的多线程应用程序,使用 WasmFS 和 mimalloc 可以带来显著的好处。两者都可以通过使用本文介绍的标志重新编译,在 Emscripten 项目中轻松尝试。

即使您当前没有使用线程,也建议尝试这些功能。如前所述,这些最新的实现在单核场景下也包含了显著的优化,可能带来性能提升。

标签: WebAssembly

评论

发表评论

正在加载评论...