これは何
Crystal 言語を使って自社のソーシャルサービス用にレコメンドを実運用したのでその経験をシェアします。過去にも一度触ったことは合ったのですがその時は言語仕様が大きく変わる時期だったのでしばらく時間をおいていましたが予定されていた大きな変更は終わったようだったので久しぶりに再度挑戦してみました。
Crystal 言語とは
この記事をクリックした人には説明が不要かと思いますが Crystal は Ruby とよく似たシンタックスを持つ静的型付きコンパイル言語です。LLVM上で動き、以下のような特徴を持っています。
- 高速
- 省メモリ
- Go と同様のコルーチンベースの自然な非同期モデル
- Null 安全
- 型推論
レコメンドアルゴリズム
Fast Matrix Factorization for Online Recommendation with Implicit Feedbackという論文を参考に実装しました。
これは Matrix Factorization 系の最近のアルゴリズムなのですが、既存手法に比べて高速というのとオンライン更新が可能ということで実装してみました。Matrix Factorization と言いつつも行列ライブラリを必要としない(というか使えない)アルゴリズムになっているのが Crystal を実装言語として選んだ理由の一つです。
論文の内容については3行で説明できる能力が無いので興味のある方は読んで下さい。直接の行列計算を避けて近似解を求める比較的シンプルなアイデアです。
私のユースケースではまだ各種パラメータが小さいので学習時間は1分ほどで終わります。
なのでオンライン学習は実装せずに2時間に一度フル学習し直すという乱暴なことをしました。
フレームワーク
Kemal を使いました。このレコメンドエンジンは Rails の裏で動いていていて認証等は実装する必要が無いので正直なんでも良かったです。
良かったところ
コーディング
- Rubyとよく似たシンタックスなので非常にサクサク書くことができました。論文読んで動くところまで3日くらい。
- 型エラー、nilエラーを事前に検知してくれるので実行時例外は随分減りました。
運用
- やっていることがただの数値計算なので非常に安定しています。500エラーおよび不可解な問題は一度も出ていません。
- 平均レスポンス時間は 10ms です。1万件程度のベクトルの内積をリクエストごとに毎回計算をしてスコアリングしているのにこの速度が出るのは非常に満足です。
- デプロイはバイナリ一つを配置するだけなのでこれ以上無いくらいに簡単です。
- メモリを全然食いません。ユーザとアイテムそれぞれ 10,000 件ほど読み込んで大体 70MB しか消費しません。Ruby の 1/5 くらいのメモリ消費量ではないかと思います。これは思っていたよりもポジティブなインパクトがありました。
良くないところ
コーディング
- 当初Atom+linterを使っていましたが、linter がライブラリを見つけてくれず断念して emacs+crystal-mode に切り替えました。
- ハッシュのアクセスに
[]
を使ってしまうとキーが見つからなかった時に実行時例外が起こってしまいます。Crystal で実行時例外が起きるのは全く嬉しくないのでfetch
を常に使いましょう。 - コンパイルはサクサクという感じではないですね。自前のコード部分が1,300行、フレームワークなどライブラリが6,000行でコンパイルに7秒かかります。リリースビルドだと45秒。今のところ問題ではないですが、大きなシステムを作ると問題になりそうです。
- 一度に一つのコンパイルエラーしか出してくれないのでコンパイル通すまでにもたつく場面がありました。linter ちゃんと使っていれば起きない問題だとは思います。
- Java などのかっちりした言語に比べるとやはりIDEによるサポートはあまり望めなさそうです。感覚ですが、Typescript と同程度ではないかと思います。
- Int32, Float64 など数値系の型がCのようにバイト数で区分されており、 Ruby のように無限に自動拡張しません。その為オーバーフローが起こる可能性を考慮する必要があります。(その分高速ではある)
ハマった箇所
- コンパイラがたぶんインクリメンタルビルドのためにキャッシュを持っているのだと思うのですが、時々コンパイラがSEGVで落ちることがありました。再度コンパイルしてみるとなぜか通ります。最近は起きなくなったので初期に書いたコードに何か問題があったのかな?基本的な言語機能でトラブルが起きることは無いですが、型が関係してくるとSEGVが起きることがある印象です。
- mysql の接続には crystal-mysql という新しい crystal 製のライブラリを使ってみたのですが、読み込みバッファの取扱にバグがあり、一定以上のデータ量を読み込むと結果が壊れるという問題が起きました。まだライブラリが若いのでこういうトラブルは発生し続けると思います。
まあでも最近の言語だとどれ使ってもこの程度のトラブルで予期しない時間を消費することはあるかと思います。個人的にはJSのビルド周りでハマったときの手がかりの無さと経験値の溜まらなさのほうが辛かったです(ビルドが無言で落ちるなど)。
Crystal のすごいところ
以上 Crystal を使ってみてよかったところを書いていきましたが、以下では技術的な視点からもう少し掘り下げたいと思います。最近プログラミングから離れているので間違いがあったらすみません。
省メモリ
Crystal はCのようなコンパクトなデータ表現をしているため非常に省メモリです。例えば Int32 は 32bit 整数ですが、文字通り32bit=4byteしか消費しません。Ruby の Integer は RVALUE
のサイズに等しいので 40byte となります。今回のようなリコメンドのような用途ではこれはかなり大きな違いを生むと思います。StringやArrayでは違いはそれほどでないと思います。
Ruby でややこしい処理を書いていたときは常にメモリ不足に悩まされてきました。メモリ不足が原因でスクリプトが途中でこけ、その原因調査、改修に時間を取られていました。そこに消費していた手間と時間を思うとまさにストレスフリーという感じです。
消費メモリが少ないのでダウンタイムの少ないリスタートも可能になりました。サービスの再起動をするときに既存プロセスは残したまままず新プロセスでデータの読み込みと学習を完了させて、Listen できる状態になってから既存プロセスを kill して即座に Listen するという仕組みを取りました。メモリ消費量の少ないサービスだからできる技だと思います。
コルーチンベースの非同期
CrystalではGoと同様のコルーチンベースの非同期モデルを採用しています。Node, Nginx, Elixir, Go など速いと謳われるものが大体非同期の仕組みを根幹レベルで組み込んでいることからも非同期の仕組みはパフォーマンスを語る上で非常に重要な要素だと思います。
Kemalフレームワークを使うと自動的に並行処理されるようになります。Crystal は現時点ではシングルスレッドしか使えません(ただしGCのみ別スレッドで動く)が、wrk
等でベンチマークをかけてみると、スレッド数が 4 の時のrpsはスレッド数1のときの rps の倍くらいになりました。ここから MySQL の待ち時間などに別のリクエストを処理しているであろうことが読み取れます。でもサービスのコード自体は一切非同期を意識していません。
コルーチンベースの非同期の仕組みが通常のマルチスレッディングよりも速い理由は以下のとおりだという理解です。
- スレッドの切り替えが起きないためスイッチングコストが非常に低い(CPUキャッシュの効率が良い)
- スレッドの数がCPUの数程度と少ないのでスレッドごとのスタック領域に消費されるメモリが大した事ない
- IO が起きると別のタスクに切り替わる仕組みなのでスイッチングの頻度が低い(合計スイッチングコストが少ない)
このようなメリットを同期的プログラミングのメンタルモデルのままで享受できるのはチーム開発上もメリットだと感じています。私は日頃ベトナム人と仕事をしていますが、Javascriptの非同期モデルとエラーハンドリングを新人に理解させるのは骨が折れます。
ただし当然Cバインディングを使ってしまうとこのメリットが台無しになります。
最適化戦略への影響
省メモリ・高速・非同期という要素が揃うことの帰結として言えることとして、パフォーマンスチューニングの勘所が変わってくるだろうなと感じました。具体的にはGoやCrystalのような言語でコードが遅かった場合、その原因はアルゴリズムやSQLのようなサービスにとって本質的な部分が原因で起きる頻度が高くなるのでは、と思います。
最後に
Crystalをプロダクションで人に勧めるかというとまだ怖くて勧められません。まだ言語仕様に破壊的変更があるかもしれないですし、ライブラリにクリティカルなバグが残っていたりします。今回自分が Crystal を選んだのは
- アルゴリズムの性質上既存のライブラリが特に不要
- 実行速度が速くなければいけなかった
- 出来る限り速くリリースすることが最優先事項だった
- Crystalは標準ライブラリを含め全部Crystalで書かれているので個人的にはトラブル解決がかなりやりやすい
- 3日で作れるようなサービスだったので将来メンテで問題が起きたら別の言語なりでまた3日で作れば良い
という理由からです。
今後起こりうるCrystalの適用場面(自分のユースケースに近い範囲で)としては以下の2つがあるかなーと感じました。
- スタートアップのような開発速度が求められる領域での初期開発に Ruby の代わりに一部 Crystal を使う。開発速度を保ちつつ、パフォーマンスが良いのでスケールするときの痛みを抑えられるのでは無いかと思います。
- 機械学習系もライブラリが揃えば盛り上がる可能性はあると思いました。Python のように速度の必要な部分がCで実装されるということが無いのでより自然なライブラリ群が作れるのでは無いかと思います。