A practical guide to developing wasm components using Rust for web front-end engineers

What are wasm components?

The full name of wasm is WebAssembly. It is a binary program that can be executed in environments such as servers and clients such as browsers through virtual machines. It is fast, efficient and portable.

The biggest benefit to our Web front-end project is that we can use binary programs on the browser side to handle some computationally intensive processing, and use its faster characteristics than JavaScript to optimize performance.

The current browser compatibility with wasm is as follows:

https://img10.360buyimg.com/imagetools/jfs/t1/180904/35/36038/170761/64ded9bdF6f54c383/e85e037cdd4fa1fd.jpg

On the mobile side, except for Android 4.4 and iOS 10, which are not supported, other versions can provide support. It should also be noted that wasm may occupy a large amount of memory. When using third-party components that include wasm calls, you need to pay attention to the memory usage to prevent crashes.

Why use Rust?

The wasm module can be compiled in multiple languages, including C/C++/C#, Rust, JAVA, and Go. Rust is used here because it has a strict memory management mechanism and syntactically tries to avoid memory overflow, allowing engineers to write safer programs.

There is also a supporting tool, wasm-pack, which allows codes written in Rust to be compiled and packaged into npm packages, so that other codes using this program can be called like other public libraries without additional learning costs.

Tool installation

  1. Install rustup, which is a Rust installer and version management tool. For the web front-end, it is equivalent to tools like nvm. Install according to the method on Rust's official website: https://www.rust-lang.org/zh-CN/tools/install. Cargo will also be installed, which is Rust's build tool and package manager. For web front-ends, it is equivalent to tools like npm.

  2. Install wasm-pack, which is the tool mentioned above that compiles and packages Rust programs into wasm components. Also install according to the wasm-pack official website: https://rustwasm.github.io/wasm-pack/installer/

  3. 使用 wasm 模板 使用 wasm-pack 提供的模板可以快速生成 Rust 的 wasm 项目。

cargo generate --git https://github.com/rustwasm/wasm-pack-template

输入希望的项目目录名称,将新建目录并在其中生成项目。

在目录下我们可以看到几个文件,其中一个是 Cargo.toml ,这个是 Rust 项目的描述文件,对于 web 前端来说相当于 package.json 文件。

项目目录下还有一个 src 目录,里面有 lib.rs 和 utils.rs 两个文件,其中 lib.rs 这个文件就是我们主要的逻辑入口,他引用了 wasm-bindgen 库来输出暴露给外部调用的接口,在函数之前加上#[wasm_bindgen]可以让外部调用这个方法。

编译项目

本来 Rust 的项目编译用的是 cargo build 的命令,但是我们这里是希望编译 wasm 组件,所以用的是 wasm-pack build 命令。

执行后会在项目目录下的 pkg 目录下生成编译后的产品,是一个 npm 包的结构。需要调用这个组件的逻辑只需要像其他公共包一样 import 就可以使用了。

实战

以上的就是 wasm-pack 官方的教程,还有其他组件测试、发布等的流程先不在这里介绍了。以下用一个实际开发中的模块来说一下开发 wasm 组件过程中遇到的问题和解决方法。

背景

需要使用的 wasm 组件是一个优化3D模型的方法,传入一个模型的顶点信息和距离阈值,比较每个顶点位置之间的距离,如果没达到阈值距离就合并这两个顶点,以达到减少顶点的优化目的。

原逻辑是使用 javascript 编写的,在模型顶点数量比较多的时候执行的时间比较长。这种大量计算的情况就很适合使用 wasm 来处理。

数据传递

顶点信息是存储在一个 Float32Array 的数组中的,而 wasm 设计上除了 int 和 float 类型(对应 javascript 就是 number 类型)可以直接传递外,其他的类型都通过地址来传递。这对我们的程序来说是好消息,因为顶点信息的数据非常多,如果以值传递,就需要做数据复制,这个过程消耗的时间可能比我们换成 wasm 处理减 少的时间还要多。得益这个特点,我们的入参可以直接传入。

/*---  rust ----*/

// rust 获取 javasctipt 数据
pub fn add_attribute(&mut self, attribute: &Float32Array, item_size: u32) {
    self.attributes.push(BufferAttribute {
        array: attribute.to_vec(),
        item_size,
    });
}
/*---  javascript ----*/

// javascript 传递数据到 rust
for (const name of attributeNames) {
  const attr = attrArrays[name]
  bg.add_attribute(attr.array, attr.itemSize)
}

而计算后的结果,wasm 也提供了返回数组的指针和数组长度的方法,javascript 可以读取 wasm 的内存空间,根据这两个值构造新的顶点信息Float32Array。

/*---  rust ----*/

// 返回指定数据的内存指针位置
pub fn get_attribute_ptr(&self, index: usize) -> *const f32 {
  self.attributes[index].array.as_ptr()
}

// 返回指定数据的长度
pub fn get_attribute_length(&self, index: usize) -> usize {
  self.attributes[index].array.len()
}
/*---  javascript ----*/

// javascript 或取 rust 内存空间中的指定部分,构建Float32Array
const ptr = bg.get_attribute_ptr(i)
const length = bg.get_attribute_length(i)

const buffer = new attr.array.constructor(wasm.getMemory().buffer, ptr, length)

数据类型

合并顶点计算的逻辑中,有一段是这样的:每个顶点的位置、UV等信息,经过给定的精度计算后,生成一个特征值,之后比较每个顶点的特征值,如果是相同的话就表示这两个顶点可以合并。

原 javascript 版本的代码是逐个信息按顺序,加上分隔号,拼成一个字符串。

Rust 版本的代码如果也按同样的方法处理,因为顶点的信息量是不定的,有可能只有位置信息,也有可能有UV、法线、颜色等信息,所以生成的特征值字符串长度也不确定。

Rust 对於可变长度的字符串使用 String 类型,每次对字符串使用push_str方法增加内容。得到的结果 wasm 版本的执行速度跟 javascript 版本相差不大,甚至在某些情况下耗时还更多,经过逐个过程作排查,发现是在生成特征值和在表中查询特征值这个过程中花费的时间比较多。

根据程序的意图,特征值并不一定要是字符串,只需要在不同输入值的时候能够输出相关的值就可以,这跟生成 hash 值的需求是一样的,于是考虑将特征值生成替换成 hash 值计算。

因为在存储特征值的表使用了std::collections::hash_map类型,于是 hash 值也使用了其下的std::collections::hash_map::DefaultHasher类来计算

use std::collections::hash_map::DefaultHasher;

...

let mut hasher = DefaultHasher::new();

for j in 0..self.attributes.len() {
  ...

  let value = (attr.array[i * attr.item_size as usize + index as usize]
    * self.shift_multiplier)
    .trunc() as i32;
    
  hasher.write_i32(value);
    
  ...
}

let hash = hasher.finish();

需要注意的是对写入不同类型的内容,需要调用不同的方法,顶点信息中的值是正负值都用,经过精度计算后取整得到的值类型是i32,所以用write_i32来写入内容。

生成的 hash 值为u64,作为hash_mapkey记录对应顶点的序号。

替换特征值的类型之后,wasm 版本的耗时达到了 javascript 版本的 1/2,基本符合 wasm 设计的性能范围。

适配打包工具

wasm-pack 工具打包出来的 npm 包,可以直接在webpack下加载并调用运行。

我们原本的项目使用 vite 构建,vite 对import wasm 组件策略和 webpack 的不一样,vite 加载会返回一个加载方法,调用加载方法会返回一个 Promise,resolve 后才会返回跟 webpack 加载一样的 wasm 组件。

我们要对 wasm-pack 生成的产物作一些修改,假设我们的 wasm 组件命名为 merge_vertice_wasm,生成的主 js 文件应该会命名为merge_vertice_wasm.js,内容如下:

import * as wasm from './merge_vertice_wasm_bg.wasm'
import { __wbg_set_wasm } as wasm_bg from './merge_vertice_wasm_bg.js'
__wbg_set_wasm(wasm);
export * from './merge_vertice_wasm_bg.js'

为兼容 vite 的加载策略,修改成下面的内容

import * as wasm from './merge_vertice_wasm_bg.wasm'
import * as wasm_bg from './merge_vertice_wasm_bg.js'

let memory
if (wasm.default) {
  wasm.default({
    './merge_vertice_wasm_bg.js': wasm_bg,
  }).then(_wasm => {
    memory = _wasm.memory
    wasm_bg.__wbg_set_wasm(_wasm)
  })
else {
  memory = _wasm.memory
  wasm_bg.__wbg_set_wasm(wasm)
}
export * from './merge_vertice_wasm_bg.js'

export function getMemory({
  return memory
}

就可以在 webpack 和 vite 下都可以顺利加载并运行了。

其中增加了getMemory的方法供外部获取 wasm 组件的内存空间。

wasm 调用 javascript 方法

当我们在调试和测试性能表现时,需要打印日志,由于我们的 wasm 跑在浏览器环境中,我们需要调用 javascript 的方法,比如console.logconsole.time

wasm-bindgen 库提供了 web-sys 的组件,让 Rust 可以调用这些方法。

首先需要在cargo.toml中添加 web-sys 的依赖,并声明需要用到的特性:

[dependencies]
wasm-bindgen = "0.2.84"

[dependencies.web-sys]
version = "0.3.64"
features = ["console"]

这样在下次编译的时候,cargo 就会自动处理这些依赖,将会下载并构建。

然后在我们的 Rust 文件中,加入对 web-sys 的引用:

extern crate web_sys;

就可以调用 javascript 的 console 下的方法了:

// 调用console.log
web_sys::console::log_1(&JsValue::from(logContent));

// 调用console.time(label)
web_sys::console::time_with_label(label);

// 调用console.timeEnd(label)
web_sys::console::time_end_with_label(label);

原 javascript 版本优化模型耗时:

https://img14.360buyimg.com/imagetools/jfs/t1/109410/21/37527/8537/64dedd1cFe4c8c5c4/596fc2d36cc9fe5c.jpg

wasm 版本优化模型耗时:

https://img12.360buyimg.com/imagetools/jfs/t1/188745/32/36809/10529/64dedd1cF49a8b5cc/8dea820d278ad577.jpg

总结

以上为根据官网文档把模型合并顶点优化方法迁移为 wasm 版本的开发经历,从安装工具到发布、调试的整个过程。

中间因为对 Rust 数据类型的不熟悉和对不同前端构建工具对 wasm 组件处理的不同不够清晰,在开发过程中遇到的问题和解决方法。

Rust 版本的代码逻辑基本上是从 javascript 版本翻译过来的,其中应该还有在 Rust 环境下的优化手段,将在之后的学习中继续迭代。

引用

[1]

Rust 官方文档: https://doc.rust-lang.org/book/

[2]

Rust Wasm 官方介绍文档: https://rustwasm.github.io/docs/book/

[3]

wasm-pack 官方文档: https://rustwasm.github.io/docs/wasm-pack/


本文分享自微信公众号 - 凹凸实验室(AOTULabs)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

Bun 发布 1.0 正式版本,Zig 编写的 JavaScript 运行时 Windows 文件资源管理器的神奇 bug,一秒提升性能 JetBrains 发布 Rust IDE:RustRover PHP 最新统计数据:市场份额超 7 成、CMS 中的王者 将 Python 程序移植到 Mojo,性能提升 250 倍、速度比 C 还快 .NET 8 性能大幅提升,比 .NET 7 遥遥领先 JS 三大运行时对比:Deno、Bun 和 Node.js Visual Studio Code 1.82 网易伏羲回应员工“因 BUG 被 HR 威胁”离世 Unity 引擎明年起根据游戏安装量收费 (runtime fee)
{{o.name}}
{{m.name}}

Guess you like

Origin my.oschina.net/o2team/blog/10110614