Rust+WasmでFIXログパーサーを作る


Wasm FIX Parser

Rustの練習として金融関係で使われるFIXプロトコルのパーサーWebアプリをWasmで作った。以前作ったJSバージョンをRustへ移植した。

レポジトリはこちら。

FIXプロトコルとは

FIXプロトコルは証券やFXの売買で30年くらい使われている古いプロトコルで、要はキーバリューペアを一行に並べて送信するだけのもの。

例えばこのメッセージを見てみる。

35=D|49=ABANK|56=EXCHANGE|38=1000000|44=1|54=2|55=JCOM

キー35はメッセージタイプ、その値Dは注文発注などというマッピングの取り決め(プロトロル)が双方で共有されているため、以下のようにパースされる。

  MsgType (35)             : ORDER_SINGLE (D)
  SenderCompID (49)        : ABANK
  TargetCompID (56)        : EXCHANGE
  OrderQty (38)            : 1000000
  Price (44)               : 1
  Side (54)                : SELL (2)
  Symbol (55)              : JCOM

つまり、ABANKがEXCHANGEに対しJCOM株を1円で100万株の売り注文を出したと解釈される。

タグや値のマッピングは、バージョンや金融機関ごとにXMLファイルに定義するのが慣例となっている。

金融関連に長くいる人は主要なタグを暗記していて頭の中で自動的に44が価格に変換されたりするようだが、普通の人はパーサーアプリで人の読めるフォーマットにする。

ロジック

ロジックは以下のように単純明快。

  1. キーバリューの定義を含むXMLファイルを読み取り、ハッシュマップに保存する
  2. FIXメッセージをキーバリュー列のトークンに分解する
  3. 分解されたトークンを1で作ったハッシュマップを使い人の読める文字列に変換
  4. 全メッセージを処理し、ウェブサイトに表示可能な形にフォーマット

典型的なレキサー、パーサー、フォーマッターの流れ。

JavaScriptとの(適当)パフォーマンス比較

結論

wasmのほうが2倍速かった。

概説

数年前に作った同じパーサーアプリのJSバージョンとパフォーマンスをざっくりと比較してみた。

  • 方法: 1000行を読み込ませChromeのプロファイラでScript部分(Renderingではない部分)を計測する
  • 結果: Wasmバージョンは約50ms, JSバージョンは約100ms

JSバージョンでは、XMLファイルを事前にオブジェクトリテラルに変換したものを使っているため厳密には同じではない。

JSからRustへの移植で得た知見

小さいデータは積極的にclone/copyする

RustはCからの移植が重視されているためか、不要なclone/copyはアンチパターンとされている。

個人的には、以下をすべて満たす場合はコードの生産性/見通しのためあえてコピーしてよいと思う。

  • 100バイト以下のデータ
  • コピーの発生回数は高々1秒間に100回
  • 広範に利用されることを想定したライブラリではない
  • 特殊なパフォーマンス要件がない (組み込み系, 高頻度取引など)

コピーを防ぐために構造体にライフタイム注釈をつけると、implの表記も煩雑になるし、構造体を使う側もどのインプットを生かさないといけないのか気をつかう。(自分がライフタイムを使いこなせていないのが一番の理由ではあるが)

ボトルネックになっていることがわかってきてから、Rcをつかったりライフタイム注釈をつけても遅くないと感じた。

リソースファイル読み込みは include_str/include_bytesマクロを使う

Java/C#でいうリソースファイルの取り扱いがRustでは簡単安全にできることに驚いた。

XMLファイル等のコードではないファイルをバイナリに含めるには、Javaではresourcesディレクトリ, C#ではPropertiesディレクトリにファイルを配置しておく。Java/C#では、ファイルを置き忘れて実行時エラーをくらったり、単体テスト用のリソースファイルを更新し忘れてテストが失敗したりと何かと使いづらい。

JSではそもそもリソースファイル的な機能がないため、Webサーバーからfetchしてくるかwebpackのローダーを使う。fetchは遅いし、ローダーはwebpackアップグレードや移行の障害になりやすい。

Rustではinclude_str/include_bytesマクロが用意されており、XMLファイルなどをコンパイル時にconstに入れられる。

const FIX40_XML: &[u8] = include_bytes!("xml/FIX40.xml");

指定されたファイルが存在しないと、実行時エラーではなくコンパイルエラーを出してくれる。

イテレーターの長いワンライナーは避けてforループを使う

JSでは、(良くも悪くも)以下のようなワンライナーを書くことがある。

const result = arr.map(x => ...)
                  .filter(x => ...)
                  .flatMap(x => ...)
                  .reduce(...);

Rustで同じことをやろうと試行錯誤したが、長くなりそうなときは素直にforループで可変なVecpushすべきという結論に達した。理由は以下。

  • Resultの省略記法?がクロージャ内で使えない(使いづらい)
  • &*だらけになり型がわかりにくくなる。iter().filterだと**が必要になったりする。(公式リファレンスにもpossibly confusingと書いてある)
  • map内で&mut selfを使うメソッドを呼ぶと副作用が発生してしまう。対応する副作用のないメソッドがないことが多い

参考: イテレーターでResultの扱い方についてはこの記事が詳しい。