效能¶
遵循這些技巧,讓您的程式在速度和記憶體方面都能達到最佳表現。
過早最佳化¶
Donald Knuth 曾說:
我們應該忘掉小的效率提升,大約 97% 的時間:過早最佳化是萬惡之源。然而,我們不應該錯過那關鍵的 3% 中的機會。
然而,如果您正在編寫程式,並且意識到編寫語義等效、更快的版本只需要稍微修改,您就不應該錯過這個機會。
並且務必分析您的程式,以了解其瓶頸所在。對於分析,在 macOS 上您可以使用 Instruments Time Profiler,它隨附於 XCode,或者使用其中一個 取樣分析器。在 Linux 上,任何可以分析 C/C++ 程式的程式,例如 perf 或 Callgrind,應該都可以運作。對於 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 定義中使用此策略。
讓我們比較一下時間:
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"]
建立一個新的陣列。請記住:陣列字面值只是建立一個陣列實例並向其中加入一些值的語法糖,而這會在每次迭代中重複發生。
有兩種方法可以解決這個問題
-
使用元組。如果在上述程式碼中使用
{"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}"
-
將陣列移至常數。
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_key
、Hash#has_key?
和其他方法來取代這種做法。
盡可能使用結構體¶
如果您將類型宣告為 struct 而不是 class,建立其實例將會使用堆疊記憶體,這比堆積記憶體便宜得多,並且不會對 GC 造成壓力。
不過,您不應該總是使用結構體。結構體是按值傳遞的,因此如果您將結構體傳遞給一個方法,而該方法對其進行了更改,則調用者將看不到這些更改,因此它們可能會容易產生錯誤。最好的做法是僅將結構體與不可變的物件一起使用,尤其是當它們很小時。
例如
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_char
、each_byte
、each_codepoint
),或使用更底層的 Char::Reader
結構體。例如,使用 each_char
string = "foo"
string.each_char do |char|
# ...
end