6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Railsの一部にcrystalを導入して計算速度を向上させた話

Last updated at Posted at 2018-09-29

初めに

Crystalとは、文法が非常にRubyによく似たコンパイル言語です。公式サイトでは、「Cより速く、Rubyのように滑らか」と謳っています。Rubyだと時間がかかる計算をCrystalに肩代わりしてもらうことでサービスの速度向上を目指しました。

Crystal単体でのインストール方法、ビルド、実行方法などは公式サイトでわかりやすく説明されています。

ソースコードの置き換え

早速主要計算部分をソースを見ながらCrystalに置き換えていきます。
それにしても、RubyとCrystalはどのくらい似ているのでしょう。以下はRubyのコードです。

data = concat_data.group_by{|v| [v[0], v[1]]}.map{|k, v| [k[0], k[1], v.count] }}
max = data.max{|a, b| a[2] <=> b[2]}

以下はCrystalのコードです。CrystalもRubyと同様Enumerableがあり、group_byもmaxもメソッドとしてありますが上記だと理解がしにくいのであえて原始的なコーディングにしました。得られる結果は同じです。

参考:Enumerable(T)

m = {} of Tuple(UInt16, UInt16) => UInt16
fixed_m = [] of Array({UInt16, UInt16, UInt16})
points.each do |point|
  if m[point]?
    m[point] += 1
  else
    m[point] = 1
  end
end
fixed_m = m.map do |k, v|
  {k[0], k[1], v}
end
max = m.map{|k, v| v }.max
{
  "data" => fixed_m,
  "max" => max
}

ちょっと見慣れないメソッドもありますが、ほとんどRubyと同じ書き方が出来るということが分かると思います。(シンタックスハイライトもRubyでOK^^)唯一全然違うのは型宣言の部分です。

m = {} of Tuple(UInt16, UInt16) => UInt16
fixed_m = [] of Array({UInt16, UInt16, UInt16})

2行目から説明します。
CrystalにArrayはありますが、多重配列は存在しません。代わりにArrayと同じように使えるTupleというものがあるので、Tupleの配列としてArrayを定義すると、多重配列が定義できるようになります。{型, 型, ...}もしくはTuple(型, 型, ...)がtupleの書き方です。

1行目はhashです。Tuple(UInt16, UInt16)がキーでUInt16が値に当たります。
最終的に、dataとmaxをキーに持つハッシュとして出力しています。

Railsとつなげる

Rails側から関数を呼び出すにはプロセスをシェル等で直接叩いて結果を得る方法もあるかと思いますが、疎結合に出来るという拡張性を考えて、HTTPのAPIとしてCrystalサーバーを立ち上げます。データのやり取りにはjsonを使います。

kemalというフレームワークがシンプルで良さそうです。インストール、導入については例によって公式サイトを参考にしてください。

早速書いてみます。

# ヒートマップの計算を担うサーバー
require "kemal"
require "json"

module Server
  VERSION = "0.1.0"
  class ClickMoveRow
    JSON.mapping({
      data: Array({UInt16, UInt16})
    })
  end

  def click_move(points)
    m = {} of {UInt16, UInt16} => UInt16
    fixed_m = [] of Array(Tuple(UInt16, UInt16, UInt16))
    points.each do |point|
      if m[point]?
        m[point] += 1
      else
        m[point] = 1
      end
    end
    fixed_m = m.map do |k, v|
      {k[0], k[1], v}
    end
    max = m.map{|k, v| v }.max

    {
      "data" => fixed_m,
      "max" => max
    }
  end
end

## define route

include Server
post "/click_move" do |env|
  body = env.params.body["query"]
  arr = Server::ClickMoveRow.from_json(body)
  env.response.content_type = "application/json"

  click_move(arr.data).to_json
end

## run
Kemal.config.env = "production"
Kemal.config.port = 50001
Kemal.run

規模が大きくなるとファイル分離も必要そうですが、とりあえず1ファイルにまとめました。moduleで定義した関数やクラスはincludeすると使えます。まずcrystalがRailsから受け取るデータの構造は、JSON.mappingて定義します。

JSON.mapping({
  data: Array({UInt16, UInt16})
})

ごにょごにょ計算した結果、配列をto_jsonしてRailsに返してあげます。text/plainじゃないので、env.response.content_type = "application/json"は書いてあげたら親切ですね。
最後に、portやenvを指定して、crystal runで実行します。(デフォルトポートは3000なのでpumaとぶつかりました。)

Rails側でこのAPIを叩きます。

uri = "#{ENV['COMPUTE_SERVER_HOST']}/click_move"
# ojの仕様でハッシュロケットからシンボルにすると違う結果が得られる
content_json = Oj.dump({'data' => concat_data})
http_client = HTTPClient.new
return_data = Oj.load(http_client.post_content(uri, query: content_json, 'Content-Type' => 'application/json'))

APIを叩くクライアントとしてgem http_client, JSONの高速パーサーとしてgem ojを使用しています。このあたりは一般的なAPIと同じなので一例として挙げておきます。

速度検証

上記計算をおよそ40,000×3の配列に対して実行したところ、以下のような結果が得られました。(Ruby以外はRailsでのjsonパース等も含めています。)

crystal(r) 0.20s
crystal    0.28s
go         0.40s
Ruby       1.89s

実は計算の肩代わりをこの前にgoでもやろうとしたのですが、本番サーバーでffiの謎のエラーにハマってしまったので諦めました。よって、goのベンチマーク結果も載せてあります。

crystal(r)はreleaseコンパイルしたときの時間です。コンパイルに時間がかかるかわりにさらに実行速度が速くなりました、Rubyの約9倍です!
HTTPリクエスト経由であることを加味してもずいぶん速いです。

Daemon化

kemalはそのままだとフォアグラウンドで動いてしまうので、systemdを使ってdaemon化します。
Systemdを使ってさくっと自作コマンドをサービス化してみる
が詳しいので詳細は割愛します。systemd関連で困ったときは/var/log/syslogを見ると良いらしいです。

ExecStartで実行するshはrun.shとして以下のように記述しました。

cd [Railsプロジェクトroot]/compute_server/server/ && crystal build src/server.cr --release && ./server

shそのままだと動かないので、/bin/sh -c '[Railsプロジェクトroot]/compute_server/server/run.sh'としました。(このへんは何故なのかよく分からない……)

終わりに

Cより速いかどうかは検証できませんでしたが、少なくともGo+ffiより速いことは分かりました。しかもRailsとCrystalの同時開発は、似たような文法なので頭を切り替えることなく「滑らかに」記述出来ます。

Crystalもkemalもあまり情報がなくて調べるのに苦労しましたが、これが速度を追い求める旅人たちの一助になれば幸いです。

6
5
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
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?