跳至內容
GitHub 儲存庫 論壇 RSS 新聞訂閱

深入探討結構初始化效能

ysbaddaden

我開始嘗試使用 BLAKE3,並很快想評估它在 Crystal 中比 SHA256 快多少,看看是否能帶來一些免費的改進。幸運的是,有一個 shard 包裝了 官方函式庫,該函式庫針對不同的 CPU 功能(SSE、AVX2、NEON …)進行了高度優化的自訂組裝。

令我驚訝的是,效能非常糟糕。這篇文章是為了理解原因而寫下的記錄。準備好深入了解技術細節以及語言設計如何影響效能。

如果你想在家嘗試執行基準測試,這是一個 shard.yml

name: blake3-struct-test
version: 0.1.0
dependencies:
  blake3:
    github: didactic-drunk/blake3.cr
    commit: d7ac0b7ff8f766b528cc99170664402518dd78b4

讓我們進行基準測試

BLAKE3 的賣點之一是它比其他雜湊摘要快上幾個數量級;作者聲稱比 SHA256 快將近 14 倍。我寫了一個快速的基準測試,比較 Digest::Blake3Digest::SHA256 (由 OpenSSL 支援),這通常是我用例中的預設選擇,例如雜湊一個由 32 個位元組組成的 session id。

require "benchmark"
require "blake3"
require "digest/sha256"

class Digest::Blake3 < ::Digest
  # inject the class methods because the shard doesn't
  extend ::Digest::ClassMethods
end

Benchmark.ips do |x|
  bytes = Random::Secure.random_bytes(32)
  x.report("SHA256") { Digest::SHA256.hexdigest(bytes) }
  x.report("Blake3") { Digest::Blake3.hexdigest(bytes) }
end
SHA256   1.33M (753.33ns) (± 0.91%)    224B/op        fastest
Blake3   1.21M (827.21ns) (± 0.79%)  2.13kB/op   1.10× slower

而贏家是… SHA256!什麼?

讓我們來調查一下

基準測試顯示 Digest::Blake3 在每個迭代中於 HEAP 中分配了 2.13KB 的記憶體。研究 BLAKE3 演算法,這是設計使然:該演算法需要將近 2KB 的狀態才能計算雜湊摘要。這佔用了大量的記憶體,而這樣的基準測試分配記憶體只是為了立即丟棄它。重複的 HEAP 分配會降低速度,因為它會對 GC 造成壓力(它需要定期標記/掃描記憶體,這是一個緩慢且阻塞的操作)。

我們只需要將十六進位字串分配到 HEAP 中。2KB 被分配並丟棄,所以也許我們可以嘗試將它們放在堆疊上並直接呼叫 C 函式?讓我們驗證一下是否能改善情況。

require "benchmark"
require "blake3"
require "digest/sha256"

def blake3_hexstring(data)
  hasher = uninitialized UInt8[1912]
  hashsum = uninitialized UInt8[32]
  Digest::Blake3::Lib.init pointerof(hasher)
  Digest::Blake3::Lib.update pointerof(hasher), data, data.bytesize
  Digest::Blake3::Lib.final pointerof(hasher), hashsum.to_slice, hashsum.size
  hashsum.to_slice.hexstring
end

Benchmark.ips do |x|
  bytes = Random::Secure.random_bytes(32)
  x.report("SHA256") { Digest::SHA256.hexstring(bytes) }
  x.report("Blake3") { blake3_hexstring(bytes) }
end
SHA256   1.33M (754.01ns) (± 1.09%)   225B/op   3.40× slower
Blake3   4.51M (221.76ns) (± 2.57%)  80.0B/op        fastest

我們現在每個摘要只分配 80 個位元組(用於十六進位字串),而 BLAKE3 快得多!我們遠遠沒有達到 14 倍的說法,但要雜湊的資料很小,而且 C 函式庫為我的 CPU 選擇了 SSE4.1 組裝;AVX512 組裝可能會更快,但我的 CPU 不支援它。

讓我們將其重構為慣用的 Crystal

隨著效能的回升,我繼續重構 shard,將 C 函式封裝在 struct 內,以便我們獲得一個美觀、慣用且最佳化的 Crystal。最好的情況是我們可以直接使用結構,例如在一次性使用 Digest::Blake3.hexdigest 的情況下。最壞的情況下,我會將其嵌入到類別中,用於需要分配 2KB 在 HEAP 中的 yield 和串流情況,但是要雜湊的訊息越長,初始 HEAP 分配的影響就越小。

require "benchmark"
require "blake3"
require "digest/sha256"

struct Blake3Hasher
  def initialize
    @hasher = uninitialized UInt8[1912]
    Digest::Blake3::Lib.init(self)
  end

  def update(data)
    Digest::Blake3::Lib.update(self, data.to_slice, data.bytesize)
  end

  def final(hashsum)
    Digest::Blake3::Lib.final(self, hashsum, hashsum.size)
  end

  def to_unsafe
    pointerof(@hasher)
  end
end

def blake3_hexstring(data)
  hasher = Blake3Hasher.new
  hashsum = uninitialized UInt8[32]
  hasher.update(data)
  hasher.final(hashsum.to_slice)
  hashsum.to_slice.hexstring
end

Benchmark.ips do |x|
  bytes = Random::Secure.random_bytes(32)
  x.report("SHA256") { Digest::SHA256.hexdigest(bytes) }
  x.report("Blake3") { blake3_hexstring(bytes) }
end
SHA256   1.34M (744.61ns) (± 0.52%)   225B/op   1.38× slower
Blake3   1.85M (539.81ns) (± 1.22%)  80.0B/op        fastest

而…效能正在急劇下降。

發生什麼事了?

我們仍然只在 HEAP 中分配 80B,因為結構分配在堆疊上,那麼到底發生什麼事了?在 Crystal 中,封裝結構不是應該是免費的抽象嗎?因為這裡顯然不是這種情況。難道 LLVM 無法內聯結構和方法呼叫,而且 BLAKE3 如此之快,以至於任何開銷都會降低效能?

這裡沒有更多的提示來理解發生了什麼。我們必須深入研究生成的程式碼才能進行進一步調查。我為上面的基準測試產生了 LLVM IR

$ crystal build --release --emit llvm-ir --no-debug bench.cr

上面的指令會在目前目錄中產生一個 bench.ll 檔案。我們在發布模式下建置,以便 LLVM 優化程式碼,並告訴 Crystal 不要產生偵錯資訊以提高可讀性。遺憾的是,我在那裡找不到任何奇怪的東西。

注意

當我撰寫這篇部落格文章時,我注意到 LLVM IR 顯示與下面反組譯碼完全相同的問題。我不知道我怎麼沒注意到…也許我忘記了 --release 🤦

讓我們更深入地檢查我的 CPU 產生的組裝。例如,我們可以使用 objdump -d 反組譯可執行檔,而這次我立即注意到一些奇怪的事情:函式呼叫 Blake3::Hasher.new,後面跟著幾頁的 MOV 指令,而直接 C 函式呼叫的反組譯碼只是一些指令和函式呼叫。

這是緩慢 struct 案例的反組譯碼。查看其餘的反組譯碼,當初始化 @hasher 時,Blake3Hasher.new 內也發生了同樣的事情:重複的 MOV 指令太多,無法計算。

000000000003cf70 <~procProc(Nil)@bench.cr:35>:
   (... snip...)
   3cfa0:       e8 0b 1d 00 00          callq  3ecb0 <*Blake3Hasher::new:Blake3Hasher>
   3cfa5:       48 8b 84 24 10 16 00    mov    0x1610(%rsp),%rax
   3cfac:       00
   3cfad:       48 89 84 24 00 07 00    mov    %rax,0x700(%rsp)
   3cfb4:       00
   3cfb5:       48 8b 84 24 08 16 00    mov    0x1608(%rsp),%rax
   3cfbc:       00
   (... snip: the above 2 MOV are repeated with a different index ...)
   3ec3b:       4c 8d bc 24 28 07 00    lea    0x728(%rsp),%r15
   3ec42:       00
   3ec43:       4c 89 ff                mov    %r15,%rdi
   3ec46:       48 8b b4 24 10 07 00    mov    0x710(%rsp),%rsi
   3ec4d:       00
   3ec4e:       48 8b 94 24 08 07 00    mov    0x708(%rsp),%rdx
   3ec55:       00
   3ec56:       e8 e5 32 01 00          callq  51f40 <blake3_hasher_update>
   (... snip ...)

這是發布版本的反組譯碼(LLVM 內聯了 C 呼叫)。它非常小,我不需要剪輯任何東西。它相當易讀,即使你不懂組合語言(我自己也不太懂):它在堆疊上保留空間 (0x7b0),儲存/還原被呼叫者儲存的暫存器並呼叫函式

000000000003cfb0 <~procProc(Nil)@bench.cr:44>:
   3cfb0:       41 57                   push   %r15
   3cfb2:       41 56                   push   %r14
   3cfb4:       53                      push   %rbx
   3cfb5:       48 81 ec b0 07 00 00    sub    $0x7b0,%rsp
   3cfbc:       48 8b 5f 08             mov    0x8(%rdi),%rbx
   3cfc0:       4c 63 37                movslq (%rdi),%r14
   3cfc3:       4c 8d 7c 24 38          lea    0x38(%rsp),%r15
   3cfc8:       4c 89 ff                mov    %r15,%rdi
   3cfcb:       e8 20 16 01 00          callq  4e5f0 <blake3_hasher_init>
   3cfd0:       4c 89 ff                mov    %r15,%rdi
   3cfd3:       48 89 de                mov    %rbx,%rsi
   3cfd6:       4c 89 f2                mov    %r14,%rdx
   3cfd9:       e8 02 17 01 00          callq  4e6e0 <blake3_hasher_update>
   3cfde:       48 8d 5c 24 18          lea    0x18(%rsp),%rbx
   3cfe3:       ba 20 00 00 00          mov    $0x20,%edx
   3cfe8:       4c 89 ff                mov    %r15,%rdi
   3cfeb:       48 89 de                mov    %rbx,%rsi
   3cfee:       e8 3d 1f 01 00          callq  4ef30 <blake3_hasher_finalize>
   3cff3:       c7 44 24 08 20 00 00    movl   $0x20,0x8(%rsp)
   3cffa:       00
   3cffb:       c6 44 24 0c 00          movb   $0x0,0xc(%rsp)
   3d000:       48 89 5c 24 10          mov    %rbx,0x10(%rsp)
   3d005:       48 8d 7c 24 08          lea    0x8(%rsp),%rdi
   3d00a:       e8 41 fd ff ff          callq  3cd50 <*Slice(UInt8)@Slice(T)#hexstring:String>
   3d00f:       48 81 c4 b0 07 00 00    add    $0x7b0,%rsp
   3d016:       5b                      pop    %rbx
   3d017:       41 5e                   pop    %r14
   3d019:       41 5f                   pop    %r15
   3d01b:       c3                      retq
   3d01c:       0f 1f 40 00             nopl   0x0(%rax)

所有 MOV 指令看起來像是我們…正在複製結構?

我嘗試將結構分配到堆疊上,然後呼叫它的 init 方法 —它僅僅呼叫 #initialize,但我們無法直接呼叫它,因為 #initialize 方法在 Crystal 中受到保護。 blake3_hexstring 方法中唯一的變更是

hasher = uninitialized Blake3Hasher
hasher.init
SHA256 944.38k (  1.06µs) (± 0.97%)   225B/op   3.50× slower
Blake3   3.30M (302.91ns) (± 2.24%)  80.0B/op        fastest

效能終於與直接呼叫 C 函式並駕齊驅。這表示 LLVM 現在正在最佳化抽象。查看反組譯碼,產生的區塊與我上面列出的直接 C 函式呼叫相同。 LLVM 在最佳化結構方面做得非常出色!

發生什麼事

結構先被初始化,然後使用太多組裝指令來計算,複製 2KB 的資料。當然,這一切都發生在堆疊上,但是複製所有這些資料需要大量的 CPU 時間,而且似乎還複製了兩次?難怪效能會被破壞。

結構的初始化方式與類別完全相同:透過建構子方法。雖然類別傳回參考(一個指標),但結構傳回必須複製的值本身,這可能是一個昂貴的操作。這是問題的根源。Crystal 程式碼產生器始終會產生一個看起來像這樣的建構子方法

struct Blake3Hasher
  def self.new(*args, **kwargs) : self
    value = uninitialized self
    value.initialize(*args, **kwargs)
    value
  end
end
提示

我們可以說問題源自 Ruby 語法。.new 建構子非常好,並且適用於類別,但是該設計不太適合 Crystal 結構,因為它鼓勵 LLVM 無法最佳化的複製操作。其他語言,例如 C、Go 或 Rust 沒有這個問題,因為它們不是物件導向的語言:你宣告一個變數(未定義),然後呼叫一個函式來初始化它(帶有指向結構的指標)。

因此,問題的關鍵在於:當我們初始化一個結構時,結構會複製到堆疊上。當它只有幾個位元組時,幾乎不會被注意到,但是一旦結構變大,就會很痛苦。LLVM 會在發布模式下將所有內容內聯到一個方法中,並且它確實最佳化了結構及其方法,但是它不會最佳化複製操作,這很令人驚訝。

我們可以修復它嗎?

Crystal 程式碼產生器可以宣告堆疊上的變數,然後呼叫 #initialize,而不是為結構產生建構子,就像我上面所做的那樣。儘管如此,說起來可能比做起來容易得多;例如,如果你的結構上有任何自訂建構子,則問題會再次出現,但是也許 Ameba 可以警告它?

額外內容

我們看到 LLVM 有時會最佳化掉複製,有時則不會。但是閾值是什麼?我進行了一些測試並閱讀反組譯碼或發布版本後,我發現

  • 當 ivar 為 Pointer (64 位元) 時,結構體會完全被抽象化;
  • 當 ivar 為 UInt64UInt64[1] (64 位元) 時,儘管結構體與具有相同對齊方式的指標大小完全相同,組合語言會稍有變化 (多了幾個 MOV 和 LEA 指令);
  • 當 ivar 為 UInt64[2] 或兩個指標 (128 位元) 時,組合語言開始使用一些 XMM 暫存器來複製結構體。
提示

當結構體包裹一個指標時,它是一個自由的抽象 (就執行時間而言)。一旦它包裹任何其他東西或更多數據,就會涉及一些額外開銷。