🚀

Rust+WASMでWebクローラーのXMLパースを高速化

2021/12/21に公開


本記事はストックマークAdvent Calendarの21日目の記事です。

はじめに

こんにちは、ストックマークの谷本です。

ストックマークでは、ビジネス情報に特化したデータプラットフォームを独自に構築しています。
本記事では、そのデータプラットフォームの中核であるWebクローラーのパフォーマンスを、Rust+WASMでコスパ良く改善できたという事例を紹介したいと思います。

何が問題だったか

Webクローラーは、国内外のニュースサイトや企業サイト、ブログを回覧してビジネス情報をデータ化します。

そのさい、読み込むSitemapやRSS(これらはXML形式で配信されています[1])のサイズが大きいと解析にかなり時間がかかり、想定していた処理時間を超えてタイムアウトエラーを起こすケースがありました[2]。それが少数のサイトであればまだインパクトは小さいですが、回覧するサイトは日々増えており、Webクローラー全体の処理時間もかなり増えていました。

ストックマークでは、Webクローラーによって網羅的に収集したビジネス情報からいち早くインサイトを顧客に届けることを目指しているので、改善する必要がありました。

XMLパース処理の検討

調査を進めていくなかで、処理時間が増大してる原因の一つがXMLのパースであるとわかりました。ストックマークのWebクローラーはJavaScriptで開発されており、XMLのパースはxml2jsをラップする形で実装されていたので、xml2jsの代替となるXMLパーサーを探し、より処理時間の短い実装を置き換えられないかを検討しました。

JS実装のXMLパーサーのなかでは、fast-xml-parserを比較対処としました。fast-xml-parserはxml2jsに対する速度比較も載っており、処理時間の低下を見込める選択肢です。

また、同期的に処理しつづけるパーサーの性質上、WASM実装も高速化の選択肢に入ると考えられました。そのなかで、Rust実装のXMLパーサーのなかで速度を売りにしているquick-xmlを比較対象にしました。

WASM変換の検証実装

今回注目するのは、入力をXML文字列、出力をXML構造をマップしたObjectとする、JSから呼び出せるパース処理の速度です。JSのパッケージであるxml2jsとfast-xml-parserはパッケージをimportして呼び出すだけですが、Rustのクレートであるquick-xmlはWASMに変換する必要があります。

Rust→WASMへコンパイル→JSから呼び出しの流れはMDNのドキュメントにまとまっており、今回の検証でも同様の方法を取りました。以下、要点をまとめていきます。

関係のあるファイルを列挙すると以下のようになります。

.
├── Cargo.toml
├── src
│   └── lib.rs
├── pkg
│   └── myparser.js
└── index.mjs
Cargo.toml
[package]
name = "myparser"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
console_error_panic_hook = { version = "0.1.6", optional = true }
quick-xml = { version = "0.22.0", features = [ "serialize" ] }
serde = { version = "1.0", features = [ "derive" ] }

[features]
default = ["console_error_panic_hook"]
src/lib.rs
use quick_xml::de::from_str;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Debug, Deserialize, PartialEq, Serialize)]
struct Publication {
    name: String,
    language: String,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
struct News {
    publication: Publication,
    genres: String,
    publication_date: String,
    title: String,
    keywords: String,
    stock_tickers: String,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
struct Url {
    loc: String,
    news: News,
}

#[derive(Debug, Deserialize, PartialEq, Serialize)]
struct Urlset {
    url: Vec<Url>,
}

fn set_panic_hook() {
    #[cfg(feature = "console_error_panic_hook")]
    console_error_panic_hook::set_once();
}

#[wasm_bindgen]
pub fn parse_xml(xml: &str) -> JsValue {
    set_panic_hook();
    let result: Urlset = from_str(xml).unwrap();
    JsValue::from_serde(&result).unwrap()
}

quick-xmlでは、serializefeatureを用いてXMLの構造を構造体にマッピングして解析することができます。上記のsrc/lib.rsでは、XMLの例としてGoogle NewsのSitemap形式をパースする実装になっており、PublicationNewsUrlUrlsetがXML構造に対応しています。

構造体を定義したら、quick_xml::de::from_str(xml)でxml文字列をdeserializeします。

JS側から呼び出す関数はparse_xmlです。#[wasm_bindgen]を付けて、&strを受け取ってJsValueを返します。JsValueへの変換はJS側へ返すためで、他にもJSONにして返すなどの方法がありますが、処理速度の観点では大きな差異はありません。(構造体のインスタンスのままではJS側へ返せません)。

console_error_panic_hookはデバッグ時にRust側で起きたpanicをJS側に繋げてエラーメッセージを確認するためのものです。


src/lib.rsを実装したらwasm-packでコンパイルします。

wasm-pack build --target nodejs

これでpkg/にWASMファイルとそれをラップしたpkg/myparser.jsが生成されます。

JS側からの呼び出しは以下のようになります。parse_xml(xml)の返り値はObjectになっています。

index.mjs
import fs from "fs";
import { parse_xml } from "./pkg/myparser.js";
const xml = fs.readFileSync("/path/to/xml", "utf8");
const result = parse_xml(xml);
console.log(result.url[0].news.publication.name)

かなりシンプルにWASM実装を呼び出すことができました。

速度評価

それでは各パース処理の速度評価をしてみようと思います。

XMLのサイズは、現実の処理サイズのレンジである50k~50Mを評価対象としました。

速度計測ツールはJS製のbenchmark.jsを用います。

bench.mjs
bench.mjs
import fs from "fs";

import Benchmark from "benchmark";
import xml2js from "xml2js";
import XMLParser from "fast-xml-parser";
import { parse_xml } from "./pkg/myparser.js";

function asyncParseString(_xml) {
  return new Promise((resolve, reject) => {
    xml2js.parseString(_xml, (err, obj) => {
      if (err) {
        reject(err);
      }
      resolve(obj);
    });
  });
}

const xml = fs.readFileSync("/path/to/xml", "utf8");

const suite = new Benchmark.Suite;
suite
  .add("xml2js", {
    fn: async () => {
      await asyncParseString(xml);
    }
  })
  .add("fast-xml-parser", {
    fn: () => {
      const result = XMLParser.parse(xml);
    }
  })
  .add("quick-xml + wasm-bindgen", {
    fn: () => {
      const result = parse_xml(xml);
    }
  })
  .on("cycle", (event) => {
    console.log(String(event.target));
  })
  .on("complete", () => {
    console.log(`Fastest is ${suite.filter("fastest").map("name")}`);
  })
  .run({ async: true });

実行例:

$ node bench.mjs
xml2js x 2.34 ops/sec ±11.57% (10 runs sampled)
fast-xml-parser x 7.05 ops/sec ±7.60% (21 runs sampled)
quick-xml + wasm-bindgen x 9.10 ops/sec ±6.91% (26 runs sampled)
Fastest is quick-xml + wasm-bindgen

xml2js、fast-xml-parser、quick-xml + wasm-bindgenそれぞれのパース関数で、
1秒あたりの処理回数を計測し、それぞれをxml2jsの結果で正規化すると以下のようになりました。

今回のユースケースのXMLに対しては、xml2jsよりも、fast-xml-parser、quick-xml + wasm-bindgenのほうが3倍程度速く、また今回問題になっていた大きなサイズのXMLに対してはquick-xml + wasm-bindgenが一番パフォーマンスが良いことがわかりました。

製品への組み込み

上記検証ののち、Webクローラーのプロダクション環境(AWS Lambdaを利用しています)での速度パフォーマンスとリソース使用率の評価を経て、quick-xml + wasm-bindgen版がWebクローラーに組み込まれました。

最終的にプロダクション環境では、冒頭に記載したタイムアウトエラーが現時点では無くなりました。また1日のWebクローラー起動につき、合計で約25分処理時間を短縮することができました。

おわりに

ここまで、WASMによるWebクローラーのパフォーマンス向上事例を紹介しました。

JS→WASMへの移行は、型に慣れれば今回のようにコスパ良く行えるので、WASMを選択肢のひとつとして念頭におきながら今後も開発を続けていきたいと思います。

それではまた明日の記事をお楽しみに!


最後に嬉しいニュース:fukabori.fmの@iwashi86さんがストックマークにジョインされました🎉

脚注
  1. 例: Google NewsのRSS ↩︎

  2. ときどき本当に信じられないくらいでかいXMLに出くわすときがあります笑 ↩︎

Discussion