跳至內容

效能

遵循這些技巧,讓您的程式在速度和記憶體方面都能達到最佳表現。

過早最佳化

Donald Knuth 曾說:

我們應該忘掉小的效率提升,大約 97% 的時間:過早最佳化是萬惡之源。然而,我們不應該錯過那關鍵的 3% 中的機會。

然而,如果您正在編寫程式,並且意識到編寫語義等效、更快的版本只需要稍微修改,您就不應該錯過這個機會。

並且務必分析您的程式,以了解其瓶頸所在。對於分析,在 macOS 上您可以使用 Instruments Time Profiler,它隨附於 XCode,或者使用其中一個 取樣分析器。在 Linux 上,任何可以分析 C/C++ 程式的程式,例如 perfCallgrind,應該都可以運作。對於 Linux 和 OS X,您可以通過在除錯器中執行程式,然後偶爾按下 "ctrl+c" 來中斷它,並發出 gdb backtrace 命令來查找回溯中的模式(或使用 gdb poor man's profiler,它會為您執行相同的操作,或者使用 OS X sample 命令),來偵測大多數熱點。

請務必透過使用 --release 標記編譯或執行程式來分析程式,這會開啟最佳化。

避免記憶體配置

您可以在程式中執行最佳化的其中一個最佳方法是避免額外/無用的記憶體配置。當您建立一個 類別 的實例時,就會發生記憶體配置,這最終會配置堆積記憶體。建立一個 結構 的實例會使用堆疊記憶體,並且不會產生效能損失。如果您不知道堆疊和堆積記憶體之間的差異,請務必 閱讀此文

配置堆積記憶體速度較慢,並且會對垃圾收集器 (GC) 造成更大的壓力,因為它稍後必須釋放該記憶體。

有幾種方法可以避免配置堆積記憶體。標準函式庫的設計方式旨在幫助您做到這一點。

寫入 IO 時不要建立中間字串

若要將數字列印到標準輸出,您會寫入:

puts 123

在許多程式語言中,將會發生的事情是,會調用 to_s 或類似的方法,將物件轉換為其字串表示法,然後該字串將被寫入到標準輸出。這可以運作,但它有一個缺陷:它會建立一個中間字串,在堆積記憶體中,僅為了寫入它,然後將其丟棄。這涉及堆積記憶體配置,並為 GC 帶來一些工作。

在 Crystal 中,puts 將在物件上調用 to_s(io),並將字串表示法應該寫入的 IO 傳遞給它。

因此,您永遠不應該執行此操作:

puts 123.to_s

因為它會建立一個中間字串。始終將物件直接附加到 IO。

在撰寫自訂型別時,請務必覆寫 to_s(io),而不是 to_s,並避免在該方法中建立中間字串。例如:

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 而不是回傳中間字串的理念,會比處理中間字串產生更好的效能。您也應該在 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

請優先使用專用的 String.build,它是針對建構字串而最佳化的,而不是建立中間的 IO::Memory 配置。

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"] 建立一個新的陣列。請記住:陣列字面值只是建立一個陣列實例並向其中加入一些值的語法糖,而這會在每次迭代中重複發生。

有兩種方法可以解決這個問題

  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}"
    

使用元組是較佳的方式。

迴圈中顯式使用陣列字面值是建立暫時物件的一種方式,但這些物件也可以透過方法呼叫來建立。例如,Hash#keys 會在每次被調用時返回一個包含鍵的新陣列。您可以使用 Hash#each_keyHash#has_key? 和其他方法來取代這種做法。

盡可能使用結構體

如果您將類型宣告為 struct 而不是 class,建立其實例將會使用堆疊記憶體,這比堆積記憶體便宜得多,並且不會對 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 範圍內的字元始終由單個位元組表示。因此,使用 String#[] 索引字串不是一個 O(1) 操作,因為每次都需要解碼位元組以找到給定位置的字元。Crystal 的 String 在這裡做了一個最佳化:如果它知道字串中的所有字元都是 ASCII,那麼 String#[] 可以以 O(1) 的複雜度實現。然而,這通常並非如此。

因此,以這種方式迭代字串並不是最佳的,事實上它的複雜度為 O(n^2)

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

以上還有第二個問題:計算字串的 size 也很慢,因為它不僅僅是字串中的位元組數(bytesize)。但是,一旦計算出字串的大小,它就會被快取。

在這種情況下提高效能的方法是使用迭代方法之一(each_chareach_byteeach_codepoint),或使用更底層的 Char::Reader 結構體。例如,使用 each_char

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