wasm-pack で regex を使う時のビルドサイズとパフォーマンスの調査
tl;dr
- regex crate のサイズが重い(700k)
- wasm-pack 環境で単純な正規表現のユースケースなら js-sys::RegExp を使う方が速度/サイズ両面で有利
なぜ調査するか
wasm で軽量プログラミング言語を作りたいと思い、rust のパーサジェネレータを調べていた。
nom でサンプルの #ff00cc みたいなカラーコードをパースする example を wasm ビルドすると、16kb程度なのに対し、lalrpop で簡単な構文で生成した wasm binary が 697kb になってしまった。
追ってみると lalrpop は構文定義の grammar.lalrpop から grammar.rs を生成する precompile 処理と、その後のランタイム処理で使われる lalrpop-util がある。
この lalrpop-util は regex の crate を依存に持っており、 regex 実装が全て入っている。特に、unicode table 関連の定義が多い模様
色々ビルドオプションを工夫して小さくすることを試してみる。最悪 wasm 実装なので js 側の regex を借用するのもベンチ取りつつ検討する
元々のコード
#[macro_use] extern crate lalrpop_util;
use wasm_bindgen::prelude::*;
lalrpop_mod!(pub grammar); // synthesized by LALRPOP
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
pub fn parseable(input: &str) -> i32 {
match grammar::TermParser::new().parse(input) {
Ok(_) => Ok(1),
Err(_) => Err(-1),
}
}
use std::str::FromStr;
grammar;
pub Term: i32 = {
<n:Num> => n,
"(" <t:Term> ")" => t,
};
Num: i32 = <s:"1"> => i32::from_str(s).unwrap();
[package]
name = "mizl_lalrpop_parser"
version = "0.1.0"
authors = ["mizchi <miz404@gmail.com>"]
edition = "2021"
build = "build.rs" # LALRPOP preprocessing
[profile.release]
# Tell `rustc` to optimize for small code size.
opt-level = "s"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
lalrpop-util = "0.19.7"
regex = "1"
wasm-bindgen = { version = "0.2.63", features = ["serde-serialize"] }
wee_alloc = { version = "0.4.5", optional = true }
console_error_panic_hook = { version = "0.1.6", optional = true }
[build-dependencies]
lalrpop = "0.19.7"
これを wasm-pack build --out-name mod --target web -- --features wee_alloc
でビルドすると 697kb
regex を単体でビルドしてみる
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use regex::RegexSet;
#[wasm_bindgen]
pub fn regex_match(input: &str) -> Vec<i32> {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let set = RegexSet::new(&[
r"\w+",
r"\d+",
r"\pL+",
r"foo",
r"bar",
r"barfoo",
r"foobar",
]).unwrap();
let matches: Vec<i32> = set.matches(input).into_iter().map(|m| m as i32).collect();
matches.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn pass() {
let x = regex_match("foobar");
println!("{:?}", x);
// assert_eq!(regex_match("foo"), vec![1, 2, 3]);
}
}
[package]
name = "mizl_wasm"
version = "0.1.0"
authors = ["mizchi <miz404@gmail.com>"]
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = { version = "0.2.63", features = ["serde-serialize"] }
serde = { version = "1.0", features = ["derive"] }
wee_alloc = { version = "0.4.5", optional = true }
console_error_panic_hook = { version = "0.1.6", optional = true }
once_cell = "1.9.0"
[dependencies.regex]
version = "1.5"
[dev-dependencies]
wasm-bindgen-test = "0.3.13"
[profile.release]
opt-level = "s"
675K: mod_bg.wasm
つまり、lalrpop の 697k ほとんどのサイズが regex 由来なことがわかる。
regex のオプションをいじってビルドする
[package]
name = "mizl_wasm"
version = "0.1.0"
authors = ["mizchi <miz404@gmail.com>"]
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = { version = "0.2.63", features = ["serde-serialize"] }
serde = { version = "1.0", features = ["derive"] }
wee_alloc = { version = "0.4.5", optional = true }
console_error_panic_hook = { version = "0.1.6", optional = true }
[dependencies.regex]
version = "1.5"
default-features = false
# regex currently requires the standard library, you must re-enable it.
features = ["std"]
[dev-dependencies]
wasm-bindgen-test = "0.3.13"
[profile.release]
opt-level = "s"
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use regex::RegexSet;
#[wasm_bindgen]
pub fn regex_match(input: &str) -> Vec<i32> {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let set = RegexSet::new(&[
// r"\w+",
// r"\d+",
// r"\pL+",
r"foo",
r"bar",
r"barfoo",
r"foobar",
]).unwrap();
let matches: Vec<i32> = set.matches(input).into_iter().map(|m| m as i32).collect();
matches.to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn pass() {
let x = regex_match("foobar");
println!("{:?}", x);
}
}
ビルドサイズは 257kまで落ちるが、コメントアウトした3つが動かなくなる。
// r"\w+",
// r"\d+",
// r"\pL+",
---- mizl_wasm::tests::pass output ----
error output:
panicked at 'called `Result::unwrap()` on an `Err` value: Syntax(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
regex parse error:
\w+
^^
error: Unicode-aware Perl class not found (make sure the unicode-perl feature is enabled)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
)', crates/wasm/src/lib.rs:22:8
Unicode-aware Perl class を有効にする必要があるらしい。
これだと \w+ が通る
[dependencies.regex]
version = "1.5"
default-features = false
features = ["std", "unicode-perl"]
let set = RegexSet::new(&[
r"\w+",
r"\d+",
// r"\pL+",
r"foo",
r"bar",
r"barfoo",
r"foobar",
]).unwrap();
283K で +26K 増える。
\L を動かすには unicode-gencat が必要
features = ["std", "unicode-perl", "unicode-gencat"]
let set = RegexSet::new(&[
r"\w+",
r"\d+",
r"\pL+",
r"foo",
r"bar",
r"barfoo",
r"foobar",
]).unwrap();
332K.
この時点で r"あ"
も通るようになる。
ここから実行速度を計測する。
unicode 系を落として、perf を有効にしてみる。
[dependencies.regex]
version = "1.5"
default-features = false
features = ["std", "perf"]
389K。速度出すには +110K が必要
ちょっと趣向を変えて js-sys を使ってみる。ビルドサイズ自体はこちらが最小のはず。
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use js_sys::RegExp;
#[wasm_bindgen]
pub fn regex_exec(input: &str) -> bool {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let expr = RegExp::new(r"\w+", "u");
expr.test(input)
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
#[wasm_bindgen_test]
fn pass() {
assert_eq!(regex_exec("foobar"), true);
}
}
JS 側から RegExp を借用したので、当たり前だがビルドサイズはものすごく小さい。
4.8K mod.js
3.3K mod_bg.wasm
パフォーマンスは呼び出しのたびにブリッジコストがかかるのであまり良くないはず。
ここから regex, regex(perf), RegExp(js-sys) で比較していく。
use wasm_bindgen::prelude::*;
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
use js_sys::RegExp;
use once_cell::sync::Lazy;
static REGEX_EXPR: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new("\\w+").unwrap()
});
#[wasm_bindgen]
pub fn js_sys_regexp_test(input: &str) -> bool {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let expr = RegExp::new(r"(\d{4})-(\d{2})-(\d{2})", "u");
expr.test(input)
}
#[wasm_bindgen]
pub fn regex_test(input: &str) -> bool {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
REGEX_EXPR.is_match(input)
}
#[cfg(test)]
mod tests {
use wasm_bindgen_test::wasm_bindgen_test;
use crate::{js_sys_regexp_test, regex_test};
#[wasm_bindgen_test]
fn perf_js_sys_regexp() {
let now = js_sys::Date::now();
let text = "2012-03-14, 2013-01-01 and 2014-07-05";
println!("time: {:?}", now);
for (_, _) in (0..100000).enumerate() {
let result = js_sys_regexp_test(text);
assert!(result);
}
let end = js_sys::Date::now();
println!("time: {:?}", end - now);
assert_eq!(end - now, 0.0);
}
#[wasm_bindgen_test]
fn perf_regex_test() {
let now = js_sys::Date::now();
let text = "2012-03-14, 2013-01-01 and 2014-07-05";
println!("time: {:?}", now);
for (_, _) in (0..1000000).enumerate() {
let result = regex_test(text);
assert!(result);
}
let end = js_sys::Date::now();
println!("time: {:?}", end - now);
assert_eq!(end - now, 0.0);
}
}
wasm-pack test で println がうまく出力できなかったので、assert で値を見た。本来 cargo test -- --nocapture とか色々ハックがあるらしいが、wasm-pack 越しだとちゃんと動かなかった…。
同じ入力を大量に突っ込んでるのでおよそまともなベンチではないのだが、 rust で fake やその他のユーティリティが使えないのと、ブリッジコストを測りたかったので、これで良いとする。
- perf_js_sys_regexp: 378ms
- perf_regex_test(perf): 754ms
- perf_regex_test(no perf): 6757ms
perf でも JS の方が 2倍近く速い。v8 jit が効いてるからか?
このコードを書く前に、regex を static にせずに毎回評価してると、ベンチマーク不可になるぐらい遅かった。
js側から使う時のブリッジコストを確認する。
- web-sys 実装の wasm 関数を js から呼ぶ
- regex crate の wasm 関数を js から呼ぶ
- js の RegExp を js から呼ぶ
// deno test --allow-read run.test.ts
import { assert } from "https://deno.land/std@0.126.0/testing/asserts.ts";
import ready, { regex_test, js_sys_regexp_test } from "./pkg/mod.js";
await ready();
Deno.test("js => wasm (rust regex)", () => {
console.time("regex_test");
for (let i = 0; i < 100000; i++) {
assert(regex_test("2012-03-14, 2013-01-01 and 2014-07-05"));
}
console.timeEnd("regex_test");
});
Deno.test("js => wasm => js", () => {
console.time("js_sys_regexp_test");
for (let i = 0; i < 100000; i++) {
assert(js_sys_regexp_test("2012-03-14, 2013-01-01 and 2014-07-05"));
}
console.timeEnd("js_sys_regexp_test");
});
Deno.test("js => js", () => {
const re = /(\d{4})-(\d{2})-(\d{2})/u;
console.time("RegExp");
for (let i = 0; i < 100000; i++) {
assert(re.test("2012-03-14, 2013-01-01 and 2014-07-05"));
}
console.timeEnd("RegExp");
});
js => wasm (rust regex) ...regex_test: 22ms
js => wasm => js ...js_sys_regexp_test: 529ms
js => js ...RegExp: 4ms
実行速度は RegExp > Rust regex 実装 >>>> js_sys 実装という感じ
まとめ
- regex crate のビルドサイズはどう頑張っても重い
- full で 700k
- 全部落として 256k
- 現実的な選択肢 features=["std", "perf", "unicode-perl"] で 450k ぐらい
- wasm-bindgen から js-sys の呼び出しはそこまで大きくないので、ビルドサイズが第一なら検討したほうがいい
- ただしJS から呼ぶ回数が多くなると js-sys のオーバーヘッドが顕著
- 重いのは wasm_bindgen js-sys::RegExp に渡す際に string encoder 通ってるからな気はする
実際は rust のいろんなパッケージが regex のインターフェースであることを期待してるので置き換えるケースは少ないだろうが、同じインターフェースの実装を作ることができれば、js-sys::RegExp はかなり有効な選択肢になる。
で、lalrpop の実装を考えると、regex package を差し替えて同等の機能を web-sys::RegExp に差し替えるのが検討できる。lexer 差し替え機能があるのでそこを頑張る感じか