(この記事は「fukuoka.ex x ザキ研 Advent Calendar 2017」の12日目です)
昨日は、「ZEAM開発ログ v.0.2.2 Node.js と同じ原理の軽量コールバックスレッドを Elixir に実装してみた (評価編)」でした。
前回までの記事
|> ElixirのiexでとりこぼしたPIDをひろう
はじめに
こんにちは、Fukuoka.exアドバイザーズのtwinbeeことenpedasiです。
@zuminさんの Elixirで一千万行のJSONデータで遊んでみたという記事を読んでみて、「NIF経由でParseしたら、もっと性能出せるんじゃない?」ということで、今回の記事を書くことになりました。
RustlerはElixirの外部インターフェースであるNIFを利用して、C言語並のスピードで動くRustのコードを簡単に統合できるパッケージです。
Ruslter作者のHansiheさんの作品として、Rustlerを利用したJuicyというJSONパーサーがあったのでこちらで参戦したいと思います。
試した環境
Elixir 1.65 OTP 20
rustc 1.22.1
Alpine Linux
Docker 2コア/5GBメモリー割り当て
準備
まずはmix.exsの設定です。
defp deps do
[
{:flow, "~> 0.14"},
{:juicy, "~> 0.1.0"},
{:poison, "~> 3.1"}
]
依存関係を取得します。
mix deps.get
juicyは去年メンテナンスが止まっていて、それ以降の新しいErlang OTPバージョンではRustのコンパイルが通りません。反則技にはなりますがdepsの内容を書き換えます。(速度計測のための捨て環境となります)
まずは、Rustのパッケージマネージャの要Cargo.tomlから。
deps\juicy\native\juicy_native\Cargo.toml
[dependencies]
rustler = "0.16.0" #バージョン変更
rustler_codegen = "0.16.0" #バージョン変更
erlang_nif-sys = "0.6.3" # 追加
Rustlerの最新版は0.17.1ですが、NifTermがTerm型になるなど、多くの型が変更になってますので、それまでの最新版である0.16を使用します。
また、OTP20に対応するため、erlang_nif-sysの新しいバージョン定義を追加しています。
juicy使い方
ドキュメントが一切なく、テストとソースを見なさい・・というスタイルです。
テストを見ると、Juicy.parse/1で文字列をMapにすることができるようです。
Juicyは{:ok, 結果}
という形式で値を返すので、結果をパターンマッチングで取り出す必要があります。
ストリームバージョンにJuicy参戦
ストリーム版の元コードの後ろに、q2_1_juicyという関数を作りました。
def q2_1 do
"data.json"
|> File.stream!
|> Stream.map(fn d -> Poison.decode!(d) end)
|> Stream.filter(fn d -> d["age"] <= 20 end)
|> Enum.count
end
def q2_1_juicy do
"data.json"
|> File.stream!
|> Stream.map(fn r-> {:ok, map} = Juicy.parse(r); map end)
|> Stream.filter(fn d -> d["age"] <= 20 end)
|> Enum.count
end
早速実行します。
iex(6)> recompile; :timer.tc(fn -> Basic.q2_1 end)
{51471298, 2099910}
iex(5) :timer.tc(fn -> Basic.q2_1_juicy end)
{34638240, 2099910}
Stream版よりJuicy版のほうが、1.48倍速いという結果が出ました。
Flowバージョンに参戦
こちらもq2_2_juicyという関数を追加してます。
def q2_2 do
"data.json"
|> File.stream!
|> Flow.from_enumerable
|> Flow.map(fn d -> Poison.decode!(d) end)
|> Flow.filter(fn d -> d["age"] <= 20 end)
|> Enum.count
end
def q2_2_juicy do
"data.json"
|> File.stream!
|> Flow.from_enumerable
|> Flow.map(fn r-> {:ok, map} = Juicy.parse(r); map end)
|> Flow.filter(fn d -> d["age"] <= 20 end)
|> Enum.count
end
実行します。
iex(8)> :timer.tc(fn -> Basic.q2_2 end)
{36150522, 2099910}
iex(9)> :timer.tc(fn -> Basic.q2_2_juicy end)
{39878228, 2099910}
残念ながら、FlowバージョンではPure Elixir版より10%遅いという結果になってしまいました。しかも、StreamバージョンのJuicy版よりも15%遅くなってしまいました。
これは、@zacky1972 さんの「ZEAM開発ログv0.1.5」の研究結果と一致してしまいました。Flowでは、NIFの並列処理がうまくいかない現象が再現となってしまいました。
NIFはVMを止めてしまうので、並列では動かせないのかもしれませんね。
結果
pure Elixir | Juicy(NIF) | |
---|---|---|
Stream | 51.47s | 34.64s |
Flow | 36.15s | 39.88s |
NIFを使う場合はシングルプロセスで使うのが良いようです。
一方、Pure ElixirのFlowで並列処理を行った場合NIF利用に肉薄する性能を出しているので、Elixirの並列処理はなかなか良い性能と言えると思います。
おまけ
今回試したJSONは1行が小さいデータセットでした。
1オブジェクトで133KBある比較的大きなJSONを使ってJuicyの性能を試してみます。
JSONデータは開示しません。
json = File.read! "data2.json"
iex(9)> :timer.tc(fn -> Enum.reduce(1..10000, 0, fn _,_ -> Juicy.parse(json); 0 en
d)end)
{20110939, 0}
iex(10)> :timer.tc(fn -> Enum.reduce(1..10000, 0, fn _,_ -> Poison.decode!(json);
0 end)end)
{61830943, 0}
Juicy版ではPoison版の3倍の性能が出ました。
巨大なJSONをパースする用途には、Juicyは性能を発揮していますね。
おわりに
FlowとNIFについて、改めて大量データで勝負したのですが
並列でうまくいかないことが証明されてしまいました。
それと、JuicyはProduction版になることなく、メンテンナンスがストップしているのですが、Rustlerで作ったライブラリをHexにデプロイする良いサンプルであると思います。
NIFは高速ですが、使いどころはよく吟味しないといけないということがわかりました。
明日は、@kobatakoさんの「GraphQL for Elixir#2 リクエストとレスポンスの値について考える」です。