LoginSignup
7
2

More than 5 years have passed since last update.

Elixirで一千万行のJSONデータで遊んでみた Rustler編

Last updated at Posted at 2018-06-29

(この記事は「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の設定です。

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

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 リクエストとレスポンスの値について考える」です。

7
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
2