コンテンツへスキップ

パフォーマンス

これらのヒントに従って、速度とメモリの両方の観点からプログラムを最適化してください。

早すぎる最適化

Donald Knuthはかつて言いました

小さな効率性については、時間の約97%は忘れてしまうべきです。早すぎる最適化はすべての悪の根源です。しかし、その重要な3%で機会を逃すべきではありません。

しかし、プログラムを作成していて、意味的に同等で高速なバージョンを作成することが些細な変更だけで済むことに気付いた場合は、その機会を逃すべきではありません。

そして常に、プログラムのプロファイリングを行い、ボトルネックがどこにあるかを把握してください。macOSでは、XCodeに付属しているInstruments Time Profiler、またはサンプリングプロファイラのいずれかを使用できます。Linuxでは、perfCallgrindなど、C/C++プログラムをプロファイリングできるプログラムであれば動作するはずです。LinuxとOS Xの両方で、デバッガ内でプログラムを実行し、「ctrl+c」を押して時々中断し、gdbのbacktraceコマンドを実行してバックトレースのパターンを探したり(または、同じことを行うgdb poor man's profilerやOS Xのsampleコマンドを使用したり)することで、ほとんどのホットスポットを検出できます。

最適化を有効にする--releaseフラグを使用して、プログラムをコンパイルまたは実行することで、常にプログラムをプロファイルしてください。

メモリ割り当ての回避

プログラムで実行できる最適化の1つは、余分な/不要なメモリ割り当てを避けることです。メモリ割り当ては、**クラス**のインスタンスを作成するときに発生し、ヒープメモリが割り当てられます。**構造体**のインスタンスの作成はスタックメモリを使用し、パフォーマンスのペナルティが発生しません。スタックメモリとヒープメモリの違いがわからない場合は、これを読んでください

ヒープメモリの割り当ては遅く、後でそのメモリを解放する必要があるため、ガベージコレクタ(GC)への負担が増加します。

ヒープメモリの割り当てを回避するにはいくつかの方法があります。標準ライブラリは、それを行うための方法で設計されています。

IOへの書き込み時に中間文字列を作成しない

標準出力に数値を出力するには、次のように記述します。

puts 123

多くのプログラミング言語では、オブジェクトを文字列表現に変換するto_sまたは同様のメソッドが呼び出され、その文字列が標準出力に書き込まれます。これは機能しますが、欠点があります。ヒープメモリに中間文字列が作成され、書き込まれた後に破棄されます。これにはヒープメモリの割り当てが含まれ、GCに少し負担がかかります。

Crystalでは、putsはオブジェクトに対してto_s(io)を呼び出し、文字列表現を書き込むIOを渡します。

そのため、これを行うべきではありません。

puts 123.to_s

これは中間文字列を作成するためです。常にオブジェクトを直接IOに追加してください。

カスタム型を作成する際には、常にto_sではなくto_s(io)をオーバーライドし、そのメソッドで中間文字列を作成しないようにしてください。たとえば

class MyClass
  # Good
  def to_s(io)
    # appends "1, 2" to IO without creating intermediate strings
    x = 1
    y = 2
    io << x << ", " << y
  end

  # Bad
  def to_s(io)
    x = 1
    y = 2
    # using a string interpolation creates an intermediate string.
    # this should be avoided
    io << "#{x}, #{y}"
  end
end

IOに追加する代わりに中間文字列を処理するよりも、このIOに追加する方法はパフォーマンスが向上します。この戦略はAPI定義にも使用してください。

時間を比較してみましょう

io_benchmark.cr
require "benchmark"

io = IO::Memory.new

Benchmark.ips do |x|
  x.report("without to_s") do
    io << 123
    io.clear
  end

  x.report("with to_s") do
    io << 123.to_s
    io.clear
  end
end

出力

$ crystal run --release io_benchmark.cr
without to_s  77.11M ( 12.97ns) (± 1.05%)       fastest
   with to_s  18.15M ( 55.09ns) (± 7.99%)  4.25× slower

時間が改善されただけでなく、メモリ使用量も削減されていることを常に覚えておいてください。

連結の代わりに文字列補間を使用する

文字列リテラルと他の値を組み合わせて構築された文字列を直接操作する必要がある場合があります。これらの文字列をString#+(String)で連結するのではなく、文字列補間を使用してください。これにより、式を文字列リテラルに埋め込むことができます。"Hello, #{name}""Hello, " + name.to_sよりも優れています。

補間された文字列はコンパイラによって変換され、文字列IOに追加されるため、中間文字列が自動的に回避されます。上記の例は次のように変換されます。

String.build do |io|
  io << "Hello, " << name
end

文字列構築のためのIO割り当てを避ける

中間IO::Memory割り当てを作成する代わりに、文字列の構築に最適化された専用のString.buildを使用することをお勧めします。

require "benchmark"

Benchmark.ips do |bm|
  bm.report("String.build") do
    String.build do |io|
      99.times do
        io << "hello world"
      end
    end
  end

  bm.report("IO::Memory") do
    io = IO::Memory.new
    99.times do
      io << "hello world"
    end
    io.to_s
  end
end

出力

$ crystal run --release str_benchmark.cr
String.build 597.57k (  1.67µs) (± 5.52%)       fastest
  IO::Memory 423.82k (  2.36µs) (± 3.76%)  1.41× slower

一時オブジェクトを何度も作成しない

このプログラムを考えてみましょう

lines_with_language_reference = 0
while line = gets
  if ["crystal", "ruby", "java"].any? { |string| line.includes?(string) }
    lines_with_language_reference += 1
  end
end
puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"

上記のプログラムは動作しますが、大きなパフォーマンス問題があります。各反復処理で、["crystal", "ruby", "java"]に対して新しい配列が作成されます。覚えておいてください。配列リテラルは、配列のインスタンスを作成し、いくつかの値を追加するための構文糖であるに過ぎず、これは各反復処理で繰り返し行われます。

この問題を解決するには2つの方法があります。

  1. タプルを使用します。上記のプログラムで{"crystal", "ruby", "java"}を使用すると、同じように動作しますが、タプルはヒープメモリを使用しないため、高速になり、メモリ消費量が減り、コンパイラによるプログラム最適化の可能性が高まります。

    lines_with_language_reference = 0
    while line = gets
      if {"crystal", "ruby", "java"}.any? { |string| line.includes?(string) }
        lines_with_language_reference += 1
      end
    end
    puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
    
  2. 配列を定数に移動します。

    LANGS = ["crystal", "ruby", "java"]
    
    lines_with_language_reference = 0
    while line = gets
      if LANGS.any? { |string| line.includes?(string) }
        lines_with_language_reference += 1
      end
    end
    puts "Lines that mention crystal, ruby or java: #{lines_with_language_reference}"
    

タプルを使用することが推奨されます。

ループ内の明示的な配列リテラルは、一時オブジェクトを作成する1つの方法ですが、これらのオブジェクトはメソッド呼び出しによっても作成できます。たとえば、Hash#keysは、呼び出されるたびにキーを含む新しい配列を返します。それを行う代わりに、Hash#each_keyHash#has_key?などのメソッドを使用できます。

可能な限り構造体を使用する

型をクラスではなく構造体として宣言すると、インスタンスの作成にはスタックメモリが使用されます。スタックメモリはヒープメモリよりもはるかに安価であり、GCに負担をかけません。

ただし、常に構造体を使用するべきではありません。構造体は値渡しされるため、メソッドに構造体を渡してメソッドがその構造体を変更した場合、呼び出し元はその変更を認識しません。そのため、バグが発生しやすい可能性があります。最適な方法は、特に小さい不変オブジェクトに対してのみ構造体を使用することです。

例として

class_vs_struct.cr
require "benchmark"

class PointClass
  getter x
  getter y

  def initialize(@x : Int32, @y : Int32)
  end
end

struct PointStruct
  getter x
  getter y

  def initialize(@x : Int32, @y : Int32)
  end
end

Benchmark.ips do |x|
  x.report("class") { PointClass.new(1, 2) }
  x.report("struct") { PointStruct.new(1, 2) }
end

出力

$ crystal run --release class_vs_struct.cr
 class  28.17M (± 2.86%) 15.29× slower
struct 430.82M (± 6.58%)       fastest

文字列の反復処理

Crystalの文字列は常にUTF-8エンコードされたバイトを含みます。UTF-8は可変長エンコーディングです。文字は複数のバイトで表現される可能性がありますが、ASCII範囲の文字は常に1バイトで表現されます。このため、String#[]を使用して文字列にインデックスを付けることは、O(1)演算ではありません。指定された位置の文字を見つけるには、毎回バイトをデコードする必要があるためです。CrystalのStringには、ここで最適化が行われています。文字列内のすべての文字がASCIIであることがわかっている場合、String#[]O(1)で実装できます。しかし、これは一般的には当てはまりません。

このため、このような方法で文字列を反復処理することは最適ではなく、実際にはO(n^2)の複雑さになります。

string = "foo"
while i < string.size
  char = string[i]
  # ...
end

上記にはもう1つの問題があります。文字列のsizeを計算することも遅いことです。これは、文字列のバイト数(bytesize)だけではないためです。ただし、文字列のサイズが一度計算されると、キャッシュされます。

この場合、パフォーマンスを向上させる方法は、反復処理メソッド(each_chareach_byteeach_codepoint)のいずれかを使用するか、より低レベルのChar::Reader構造体を使用することです。例として、each_charを使用します。

string = "foo"
string.each_char do |char|
  # ...
end