10
2

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 1 year has passed since last update.

CrystalAdvent Calendar 2022

Day 8

Rubyからの書き換えで学ぶCrystal入門(画像生成を例として)

Last updated at Posted at 2022-12-07

はじめに

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

ターミナル上のコマンドとしてcrystalshardsが使えるようになればインストール完了です。

(参考)
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")

文字列(String)
文字(Char)

キーワード引数

上記修正をすると、次はメソッド定義でエラーがでてきました。

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ではこのように定義しておけば、位置指定の引数でもキーワード引数でも、呼び出し時に選択できるのです。

(参考)
https://crystal-jp.github.io/introducing-crystal/chapters/03-syntax.html#%E5%90%8D%E5%89%8D%E4%BB%98%E3%81%8D%E5%BC%95%E6%95%B0

標準ライブラリの違い

次のエラーは少し難しいです。

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リテラルに書き換えればよいです。

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倍近く高速化されています。さくさく画像生成できます。

最後に作成した画像です.

mandelbrot_cr.png

おわりに

少し実践的なRubyからCrystalへの移行の例を示しました。よく遭遇するRubyとの違いによるエラーは取り上げることができたかなと思います。

今回のような計算が重い例では数十倍の高速化も期待できます。実行速度が原因で Rubyから別の言語に移行を検討されている場合は、Crystalを選択肢の一つに入れていただけると良いかなと思います。

10
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
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?