初めに
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もメソッドとしてありますが上記だと理解がしにくいのであえて原始的なコーディングにしました。得られる結果は同じです。
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もあまり情報がなくて調べるのに苦労しましたが、これが速度を追い求める旅人たちの一助になれば幸いです。