はじめに
Crystalとは以下のような特徴を備えた言語です
- Rubyとよく似た構文の静的型付けコンパイラ言語
- 型推論が強力で多くの場所で型の明示が不要。Rubyの書き味に近い
- その一方で実行速度はとても高速で、場合によってはC++に迫る速度が出せる
単純なループ計算などはRubyでやるとアルゴリズム的工夫の余地がなく時間がかかってしまいます。そんなときはCrystalの出番です。実例をみんな大好きマンデルブロ集合画像の生成プログラムを例にして手順を説明してみます。この例ではRuby と比較して50倍近くの高速化ができます。
この記事は、Rubyにある程度慣れているが、Crystalは使ったことないという人向けのRubyからCrystalへの書き換えの実践例です。チュートリアルとして実際に試していただければ、Rubyと何が違うのか雰囲気がつかめると思います。普段Rubyを使っていてCrystalにも興味があるという方に読んでいただければ幸いです。
以下はCrystal 1.6を基に執筆をしています。
Rubyでマンデルブロ集合
マンデルブロ集合については以下を参照してください。マンデルブロ集合の画像を生成してpngで保存するプログラムをRubyで作ってみました。
Ruby版は以下のようなプログラムを作ってみました。
require 'chunky_png'
MaxCount = 100
class Coordinate
def initialize(xrange, yrange, image_width, image_height)
@xmin, @xmax = xrange.begin, xrange.end
@ymin, @ymax = yrange.begin, yrange.end
@xcoef = (@xmax-@xmin) / image_width.to_f
@ycoef = (@ymin-@ymax) / image_height.to_f
end
def position(x2,y2)
x = x2 * @xcoef + @xmin
y = y2 * @ycoef + @ymax
[x, y]
end
end
def mandelbrot_count(c, max_count=100, limit=10)
z = Complex(0)
max_count.times do |i|
z = z ** 2 + c
return i if z.abs > limit
end
0
end
def draw_mandelbrot(xcenter: , ycenter: , size: , image_w:, image_h:)
xmin, xmax = (xcenter-size/2.0), (xcenter+size/2.0)
ymin, ymax = (ycenter-size/2.0), (ycenter+size/2.0)
coordinate = Coordinate.new(xmin..xmax, ymin..ymax, image_w, image_h)
png = ChunkyPNG::Image.new(image_w, image_h, ChunkyPNG::Color::BLACK)
image_h.times do |py|
image_w.times do |px|
count = mandelbrot_count(Complex(*coordinate.position(px,py)), MaxCount)
color = (count/MaxCount.to_f*255).to_i
png[px, py] = ChunkyPNG::Color.rgb(0, color , color)
end
end
png.save('mandelbrot_ruby.png')
end
draw_mandelbrot(xcenter: -0.6, ycenter: 0.0, size: 2.6, image_w: 1000, image_h: 1000)
png画像生成にはchunky_pngというgemを使いました。上記プログラムを動かす場合は事前にgemを導入してください。
このプログラムでは画像の各点について最大で100回ループが回って、作画するべき色がきまります。画像サイズが今回は1000x1000なので100万点についてそれぞれ最大100回ループがまわりますので、最悪のケースで1億回ループです。これぐらいのループ回数だとRubyだと数秒かかるイメージです。
実際にやってみると
ruby mandel.rb
user system total real
14.183817 0.046925 14.230742 ( 14.279484)
14秒ぐらいかかっています。もっとさくさく画像を表示したいですね。そこでCrystalの出番です。
Crystalの準備
インストール
公式ページからInstallで適切な方法でインストールしてください。
The Crystal Programming Language
ターミナル上のコマンドとしてcrystal
とshards
が使えるようになればインストール完了です。
(参考)
https://qiita.com/ynott/items/61ec5c8fad5e78c58fa2
shardsを使う
Rubyではgemを導入してpngファイルを扱えるようにしました。Crystalではshardsをつかってパッケージを導入します。以下のサイトで検索をかけたりそこからリンクをたどったりして探してみます。
今回はstumpy_pngというshardをみつけました。これでいきましょう。
GitHub - stumpycr/stumpy_png: Read/Write PNG images in pure Crystal
shardを使う場合は、プロジェクトごとにフォルダを作成して、その中で作業します。
作業フォルダでshard.ymlがない場合は、まず、shards init
でshard.ymlを生成します。そのファイルの中にgithubのページに書いてある部分をコピペでいれます。
dependencies:
stumpy_png:
github: stumpycr/stumpy_png
version: "~> 5.0"
そして、shards install
としすると、installされます。同じプロジェクトフォルダ内のlib
以下にファイルが配置されます。
使うときは、Rubyと同じように、require "stumpy_png"
とすれば、このshardを使えるようになります。説明やドキュメントを見る限りchunky_pngとよく似てますね。わずかな書き換えで済みそうです。
CrystalとRubyは別言語といいつつ、Rubyを意識したライブラリも数多くあります。公式の標準ライブラリはRubyでお馴染みのArrayやHash、Stringなどは非常によく似たメソッドを持ちます。Rubyからの移行はそういう面でもやりやすいです。
RubyからCrystalへの書き換え
では、早速RubyからCrystalに書き換えてみましょう。今回はまずはコピーして拡張子rbからcrに書き換えます。無謀にも中身はそのままで実行しましょう。エラーをみながら Rubyとの違いを見ていきます。
ひとまずコンパイル&実行するには
crystal mandel.cr
です。あとで詳細な実行方法は触れます。
なお、インタプリタ版のCrystalも使えるようにインストールしている場合は、インタプリタモードがつかえます。
crystal i mandel.cr
実行速度は落ちるのですが、インタプリタ版はコンパイル時間がないので実行開始までとても速く、Ruby感覚でサクサク実行できて開発やデバッグ作業にオススメです。
ただし、現時点でインタプリタ版のインストールは少々面倒なところがありますので、以下の説明では通常のコンパイラ版でやります。
文字列リテラル
さっそく実行します。
crystal mandel.cr
1 | require 'chunky_png'
^
Error: unterminated char literal, use double quotes for strings
文字列のシングルクォートでなにかエラーでてますね。
Rubyでは文字列リテラルとしてダブルクォートもシングルクォートも使えますが、Crystalの文字列は、ダブルクォート"string"
です。文字列の意味でシングルクォートで書いていたものはダブルクォートに書き換えましょう。シングルクォートのリテラルはChar型つまり文字であって、文字列ではありません。
また、パッケージ名もstumpy_pngなのでついでに書き換えましょう。
require 'chunky_png' → require "stumpy_png"
png.save('mandelbrot_rb.png') → png.save("mandelbrot_cr.png")
キーワード引数
上記修正をすると、次はメソッド定義でエラーがでてきました。
In mandel.cr:28:28
28 | def draw_mandelbrot(xcenter: , ycenter: , size: , image_w:, image_h:)
Crystalでは、キーワード引数は特にメソッドの定義部分で明示しなくても使えます。むしろメソッド定義でキーワード引数を指定するコロンを入れておくとはできません。
したがって以下のように修正します
def draw_mandelbrot(xcenter: , ycenter: , size: , image_w:, image_h:)
→
def draw_mandelbrot(xcenter , ycenter , size , image_w, image_h)
Crystalではこのように定義しておけば、位置指定の引数でもキーワード引数でも、呼び出し時に選択できるのです。
標準ライブラリの違い
次のエラーは少し難しいです。
In mandel.cr:36:41
36 | count = mandelbrot_count(Complex(*coordinate.position(px,py)), MaxCount)
^
Error: unexpected token: "coordinate"
Complexという構造体がそもそも使えてないです。複素数構造体Complexを使うにはrequireしないと動きません。ですので
require "complex"
を追加します。加えて、Documentを参照すると複素数を定義するRubyのクラスメソッドに対応するものが存在しないようです。したがって、
Complex(0) → Complex.new(0)
Complex(*coordinate.position(px,py)) → Complex.new(*coordinate.position(px,py))
また累乗もないようですので
z = z ** 2 + c
→
z = z * z + c
としておきます。
このようによく似たライブラリでもメソッド名が違ったり対応するメソッドがないケースがあるので、困ったときは公式ドキュメントに行きましょう。
インスタンス変数の型推論
In mandel.cr:8:5
8 | @xmin, @xmax = xrange.begin, xrange.end
^----
Error: can't infer the type of instance variable '@xmin' of Coordinate
これがCrystalとRubyの違いを最も表すエラーかと思います。@xmin
の型推論ができないというエラーです。
Crystalは静的型付け言語なので、コンパイル時に型が決まる必要があります。しかし、型を明示しなくても動くようにCrystalは型推論によって、型をできるだけ記述しないという哲学で設計されています。これが非常に強力で殆どの場所で型を書く必要はないです。しかし例外はクラスのインスタンス変数です。
理論上はクラスのインスタンス変数もコードを追跡すればどのような型なのか推定できそうな気がしますが、クラスのインスタンス変数については、型を指定することが要求されます。これはコードの可読性やコンパイル速度のためです。
したがって、クラスについてはインスタンス変数などの型については、基本的には型推論に頼らず型がわかるような修正をする必要があります。
そのときの方針は2つあって
- インスタンス変数への代入時に右辺の型が明らかな場合は、型推論が効く。代入する変数がメソッドの引数なら引数の型推定で推論を効かせることができる。
- そうでない場合は、インスタンス変数の型を明示する。
というところです。
まず、引数の型指定は、コロンをつけて後ろに型を書きます
def initialize(xrange : Range(Float64, Float64), yrange : Range(Float64, Float64), image_width, image_height)
@xmin, @xmax = xrange.begin, xrange.end
@ymin, @ymax = yrange.begin, yrange.end
@xcoef = (@xmax-@xmin) / image_width.to_f
@ycoef = (@ymin-@ymax) / image_height.to_f
end
するとxrangeなどのbeginやendはFloat64をもつということが確定します。それらを直接代入する@xmin
や@xmax
は明白な型推論が効くので、型を明示する必要がなくなります。
しかし、上記を修正すると@xcoef
では型推論失敗です。
In mandel.cr:10:5
10 | @xcoef = (@xmax-@xmin) / image_width.to_f
^-----
Error: can't infer the type of instance variable '@xcoef' of Coordinate
@xmax
や@xmin
は型推論できたので、それを代入する@xcoef
も人間的には容易に型推論できそうです。しかし、上記原則の通り、明らかに型が明確に決まるケース以外では、型を明示する必要があります。
このような場合は以下のように直接型の明示をします
class Coordinate
@xcoef : Float64
@ycoef : Float64
...
ちなみに@xmin
や@xmax
もこのように型の明示することも可能です。
なお、ここで登場したFloat64は倍精度浮動小数点数型であり、詳しくは公式ドキュメントをご覧ください。またRangeは範囲型です。詳細はこちらも公式ドキュメントをご覧ください。
ライブラリ
ライブラリの名称が違うので、エラーが発生します。
36 | png = ChunkyPNG::Image.new(image_w, image_h, ChunkyPNG::Color::BLACK)
^---------------
Error: undefined constant ChunkyPNG::Image
chunky_pngはstumpy_pngのメソッドに書き換えます。documentを見ながら書き換えましょう
png = ChunkyPNG::Image.new(image_w, image_h, ChunkyPNG::Color::BLACK)
→ png = StumpyPNG::Canvas.new(image_w, image_h)
png[px, py] = ChunkyPNG::Color.rgb(0, color , color)
→ png[px, py] = StumpyPNG::RGBA.from_rgb_n(0, color, color, 8)
png.save("mandelbrot_rb.png")
→ StumpyPNG.write(png,"mandelbrot_cr.png")
GitHub - stumpycr/stumpy_png: Read/Write PNG images in pure Crystal
スプラット展開・複数戻り値
In mandel.cr:39:45
39 | count = mandelbrot_count(Complex.new(*coordinate.position(px,py)), MaxCount)
^
Error: argument to splat must be a tuple, not Array(Float64)
スプラット展開ができてないようです。Rubyでのスプラット展開は
def g
[1,2]
end
def f(x,y)
x + y
end
puts f(*g)
みたいな感じで、配列を引数に展開してメソッド呼び出しにわたすような書き方です。
Crystalでは、複数戻り値を戻すとき、そしてスプラット展開をするときは、配列ではなくTupleを使います。 Tupleとは、Imutableな配列、つまり変更できない配列のようなものです。リテラルは{1,2}
のような形で、Rubyではみないものですね。したがって上記プログラムは、
def g
{1,2}
end
def f(x,y)
x + y
end
puts f(*g)
とすれば動きます。
このことを踏まえてもとのエラーをみると、positionメソッドの戻り値が配列であってTupleでないことが原因です。よって、positionの戻り値を
[x, y] → {x, y}
とTupleリテラルに書き換えればよいです。
まとめ
以上でエラーがでなくなりました。動き始めているようです。結構たくさん書き換えましたが、LSPなどのエディタ上の言語補完のメッセージやコンパイルエラーなどを見ながら修正していってなれれば5分ぐらいでできます。
書き換え例は以下のとおりです。
require "stumpy_png"
require "complex"
MaxCount = 200
class Coordinate
@xcoef : Float64
@ycoef : Float64
def initialize(xrange : Range(Float64, Float64), yrange : Range(Float64, Float64), image_width, image_height)
@xmin, @xmax = xrange.begin, xrange.end
@ymin, @ymax = yrange.begin, yrange.end
@xcoef = (@xmax-@xmin) / image_width.to_f
@ycoef = (@ymin-@ymax) / image_height.to_f
end
def position(x2,y2)
x = x2 * @xcoef + @xmin
y = y2 * @ycoef + @ymax
{x, y}
end
end
def mandelbrot_count(c, max_count=100, limit=10)
z = Complex.zero
max_count.times do |i|
z = z * z + c
return i if z.abs > limit
end
0
end
def draw_mandelbrot(xcenter , ycenter , size , image_w, image_h)
xmin, xmax = (xcenter-size/2.0), (xcenter+size/2.0)
ymin, ymax = (ycenter-size/2.0), (ycenter+size/2.0)
coordinate = Coordinate.new(xmin..xmax, ymin..ymax, image_w, image_h)
png = StumpyPNG::Canvas.new(image_w, image_h)
image_h.times do |py|
image_w.times do |px|
count = mandelbrot_count(Complex.new(*coordinate.position(px,py)), MaxCount)
color = (count/MaxCount.to_f*255).to_i
png[px, py] = StumpyPNG::RGBA.from_rgb_n(0, color, color, 8)
end
end
StumpyPNG.write(png,"mandelbrot_cr.png")
end
draw_mandelbrot(xcenter: -0.6, ycenter: 0.0, size: 2.6, image_w: 1000, image_h: 1000)
コンパイル&実行
コンパイルと実行方法は上でも解説したようにRubyと同じような
crystal mandelbrot.cr
で実行できます。正式には
crystal run mandelbrot.cr
です。またインストール時にインタプリタが有効化されている場合は、
crystal i mandelbrot.cr
でインタプリタモードで実行できます。実行速度は落ちますが、コンパイル時間がないため実行までの時間が速くRubyのように手軽に実行できます。
なお、普通に実行するとコンパイルは速いですが、パフォーマンスが最適化されていません。パフォーマンスを追求するときは
crystal run --release mandelbrot.cr
とします。
また、実行ファイルを作る場合はbuildです。
crystal build mandelbrot.cr
最適化するときは
crystal build --release mandelbrot.cr
です。
パフォーマンス比較
最後にパフォーマンス比較します。Ruby、Crytalともにbenchmarkライブラリを使いました。以下は時間(秒)です。
Ruby 13.291450
Ruby(--jitあり) 9.785621
Crystal(Intepreter) 54.787306
Crystal(--releaseなし) 1.944787
Crystal(--releaseあり) 0.269153
RubyはJITを使うことでかなり高速化されます。25%近い高速化です。コードを書き換えずに高速化できるのですから嬉しいですね。Crystalインタプリタはまだ成熟しておらずRubyよりずいぶん遅いですが、コンパイルするととてもはやくなり、最適化を効かすと13.29秒から0.27秒では48倍近く高速化されています。さくさく画像生成できます。
最後に作成した画像です.
おわりに
少し実践的なRubyからCrystalへの移行の例を示しました。よく遭遇するRubyとの違いによるエラーは取り上げることができたかなと思います。
今回のような計算が重い例では数十倍の高速化も期待できます。実行速度が原因で Rubyから別の言語に移行を検討されている場合は、Crystalを選択肢の一つに入れていただけると良いかなと思います。