跳至內容
GitHub 儲存庫 論壇 RSS 新聞饋送

Fibonacci 基準測試

Ary Borenzweig

在試用 Crystal 時,很想也很開心撰寫小型基準測試,以了解該語言的效能與其他語言相比如何。由於其語法,通常與 Ruby 比較是最簡單的。很多時候我們甚至可以使用相同的程式碼。

讓我們比較斐波那契函數

# fib.cr
def fib(n)
  if n <= 1
    1
  else
    fib(n - 1) + fib(n - 2)
  end
end

time = Time.now
puts fib(42)
puts Time.now - time

讓我們比較時間。

$ ruby fib.cr
433494437
37.105234
$ crystal fib.cr --release
433494437
00:00:00.9999380

可以看出,Crystal 在效能上給了我們巨大的提升。太棒了!

然而,上述基準測試存在一個根本問題:我們沒有比較相同的函數,相同的演算法。

為了證明這一點,讓我們嘗試將數字 42 增加到 46 並再次執行程式

$ ruby fib.cr
2971215073
260.206918
$ crystal fib.cr --release
-1323752223
00:00:06.8042220

剛剛發生了什麼事?

事實證明,Crystal 有幾種整數類型,對應到電腦的整數:Int8 代表以 8 位元表示的有號數,Int32 代表以 32 位元表示的有號數,UInt64 代表以 64 位元表示的無號數,以此類推。Crystal 中整數常值的預設類型是 Int32,因此其最大值為 (2 ** 31) - 1 == 2147483647。由於 2971215073 大於此值,因此運算會溢位並產生負數結果。

另一方面,Ruby 有兩種整數類型:Fixnum 和 Bignum(儘管在 Ruby 2.4 中,它們將合併為單一的 Integer 類別)。如果整數「很小」(小於 4611686018427387903),Ruby 會嘗試使用 64 位元來表示整數,而不會配置堆積記憶體,並且將使用堆積記憶體來表示大於此值的整數。在整數之間執行運算時,Ruby 將確保在溢位時建立 Bignum,以提供正確的結果。

現在我們可以理解為什麼 Ruby 比較慢:它必須在每次運算時執行溢位檢查,從而阻止某些最佳化。另一方面,Crystal 可以要求 LLVM 很好地最佳化此程式碼,有時甚至讓 LLVM 在編譯時計算結果。但是,Crystal 可能會給出不正確的結果,而 Ruby 確保始終給出正確的結果。

我認為,Ruby 的哲學是,當在正確行為和良好效能之間做出選擇時,傾向於正確行為。從這個小範例中可以看出這一點

$ irb
irb(main):001:0> a = []
=> []
irb(main):002:0> a << a
=> [[...]]

請注意,當列印陣列時,Ruby 會注意到它已到達正在列印的相同陣列,因此它會列印 [...] 來顯示這一點。程式沒有當掉,也沒有遞迴地嘗試一次又一次列印相同的陣列。為了實作這一點,Ruby 必須記住正在列印此陣列,可能將其放入某種 Hash 中,並且當列印此陣列內的物件時,會執行雜湊查詢。

當您檢查物件並且該物件具有對自身的參照時,也會發生相同的情況

irb(main):001:0> class Foo
irb(main):002:1>   def initialize
irb(main):003:2>     @self = self
irb(main):004:2>   end
irb(main):005:1> end
=> :initialize
irb(main):006:0> Foo.new
=> #<Foo:0x007fc7429bbe30 @self=#<Foo:0x007fc7429bbe30 ...>>

這些細微之處在 Ruby 中並不是立即可見的,但是一旦您發現它們,就會讓您對 Matz 和他的團隊產生深刻的敬意。

不過,這些選擇會影響效能。如果我們希望 Crystal 對 fib 產生正確的結果(如在 Ruby 中),我們將不得不犧牲一些效能。但是,Crystal 做出此決定是因為進行大數數學運算和一直檢查溢位會影響程式的每個部分。例如,有一條 CPU 指令可以將數字加一,而 Crystal 可以利用它。Ruby 可能無法做到,因為它還需要檢查溢位。

但是,有一種方法可以在 Crystal 中獲得正確的結果,這與其他語言類似:明確使用大數。讓我們開始吧

require "big"

def fib(n)
  if n <= 1
    BigInt.new(1)
  else
    fib(n - 1) + fib(n - 2)
  end
end

time = Time.now
puts fib(BigInt.new(42))
puts Time.now - time

讓我們執行它

$ crystal fib.cr --release
433494437
00:02:28.8212840

現在我們得到正確的結果,但請注意,這比 Ruby 慢約 4~5 倍。為什麼?

我不知道答案,但我有一些猜測。也許 Ruby 的 Bignum 實作比我們用於 BigInt 的 LibGMP 函式庫更有效率。也許 Ruby 的 GC 比我們目前使用的 GC 更好,後者並不精確。也許 Ruby 針對這些情況進行了一些特定的最佳化。無論如何,我再次對 Ruby 感到深刻的敬意。

我們可以改進 fib 的效能以與 Ruby 的效能相匹配嗎?我們可以嘗試。一個簡單的方法是使用迭代方法,而不是遞迴方法,以避免建立太多 BigInt 實例。讓我們試試

require "big"

def fib(n)
  a = BigInt.new(1)
  b = BigInt.new(1)
  n.times do
    a += b
    a, b = b, a
  end
  a
end

time = Time.now
puts fib(42)
puts Time.now - time

執行它

$ crystal fib.cr --release
433494437
00:00:00.0006460

好多了!而且比 Ruby 快得多。但是,當然,我們是在作弊,因為 Ruby 仍然使用舊的、緩慢的演算法。因此,為了公平起見,我們必須更新我們的 Ruby 實作

def fib(n)
  a = 1
  b = 1
  n.times do
    a += b
    a, b = b, a
  end
  a
end

time = Time.now
puts fib(42)
puts Time.now - time

執行它

$ ruby fib.rb
433494437
3.6e-05

在這種情況下,Ruby 仍然比 Crystal 快,也許是因為在這種情況下沒有建立 Bignum。

我們還可以做些什麼嗎?

是的。Crystal 的 BigInt 目前是不可變的,但也許它可以更改為可變的,並用於這些運算效能至關重要或程式中存在瓶頸的情況。讓我們重新開啟 Crystal 的 BigInt 並做一些更改

require "big"

struct BigInt
  def add!(other : BigInt) : BigInt
    LibGMP.add(self, self, other)
    self
  end
end

def fib(n)
  a = BigInt.new(1)
  b = BigInt.new(1)
  n.times do
    a.add!(b)
    a, b = b, a
  end
  a
end

time = Time.now
puts fib(42)
puts Time.now - time

執行它

$ crystal fib.cr --release
433494437
00:00:00.0006910

嗯… 沒什麼變化。但是,如果我們嘗試使用較大的數字,例如 300,000,則時間如下

$ ruby fib.rb
# number ommited
1.880515
$ crystal fib.cr --release
# number ommited
00:00:00.7621470

似乎對於大數,並避免建立多個 BigInt 實例,Crystal 在這種情況下表現得比 Ruby 好一點。

我們可以讓 Ruby 更快嗎?我不知道。至少 Ruby 的 API 不允許更改 Bignum,所以至少現在沒有什麼可以做的。但是由於效能已經相當不錯,也許根本不需要改進任何東西。

結論

此部落格文章有幾個結論。

首先,Ruby 很棒。它力求給您正確的結果,並且以合理的效能做到這一點。Chapeau!

其次,請謹慎進行基準測試:請確保您正在測試相同的演算法,並且如果可能,請嘗試解釋語言、程式碼、框架等之間在時間(或記憶體使用量)方面可能存在的差異。

第三,Crystal 讓您可以非常高效,但它需要您(程式設計師)做更多的工作。這與 Ruby 不同。但是,Crystal 嘗試保持 Ruby 給您的幾乎相同的開發人員幸福感:編寫 BigInt.new(1) 可能會讓您感到悲傷或憤怒,但當您執行程式碼並看到它執行速度非常快時,這種感覺會得到彌補。