iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
佛心分享-SideProject30

Mongory:打造跨語言、高效能的萬用查詢引擎系列 第 9

Day 8:為什麼 Ruby 不夠快?benchmark、目標與方法學

  • 分享至 

  • xImage
  •  

Week 2 正式開始
筆者在把 Mongory 介紹給公司同事時,同事冷不防一句:「如果他能比純 Ruby 快,我才會想用它」
於是我開始了 Benchmark 測試
畢竟推向實戰時,第一個要面對的問題就是效能:純 Ruby 的 filter 與 Mongory 相比到底如何?
本篇先釐清「要比什麼、怎麼比、怎麼看」,並給出可直接複製的基準腳本,為後續 C Core 與 Bridge 篇鋪路

問題背景

  • 純 Ruby 寫法(Array#select + 區塊)效能高,但不好維護
  • Mongory(Ruby 版)條件寫成 Hash,條件式直觀且好維護,但編譯成 matcher tree 進行比對有其轉換成本,比對時有 callstack 的效能損失
  • (預告)後續走向 C,會有 Ruby ↔ C 邊界成本(轉換、GC、橋接函式),本篇先不比較

目標與衡量指標

  • 正確性優先:各方法輸出集合必須一致
  • 測量指標:
    • 吞吐量(每秒可處理筆數 / 平均時間)
    • p95/p99(若使用多輪取樣)
    • 記憶體:GC 次數/分配(可選)
  • 基準規模:1k / 10k / 100k(逐級放大);條件含基本比較與陣列/正規等混合情境

基準方法學(避免偏差)

  • 先暖機(JIT/方法快取/OS cache);丟棄第一輪結果
  • 控制隨機性:種子固定(srand
  • 減少 I/O 干擾:基準過程不印字、不寫檔
  • 單一變因:同一批資料、同一條件;依序比較「純 Ruby」→「Mongory(Ruby)」
  • 多輪取樣:至少 5 次,取中位數或 p95

可執行範例(直接複製)

require 'benchmark'
require 'json'
require 'securerandom'
require 'mongory'

Mongory.enable_symbol_snippets!
Mongory.register(Array)

def gen_records(n)
  srand(42)
  Array.new(n) do |i|
    {
      'id' => i,
      'age' => 15 + rand(15),
      'name' => %w[Ann Ben Cody Jack Jill Mary Bob].sample,
      'status' => %w[active pending inactive].sample,
      'tags' => [
        { 'name' => %w[ruby rails go js py].sample, 'priority' => 1 + rand(10) }
      ]
    }
  end
end

def plain_filter(records)
  records.select do |r|
    r['age'] >= 18 && (
      r['name'] =~ /^J/ || r['tags'].any? { |t| t['name'] == 'ruby' && t['priority'] > 5 }
    )
  end
end

def mongory_filter_rb(records)
  q = records.mongory
    .where(:age.gte => 18)
    .any_of({ :name.regex => /^J/ }, { :tags.elem_match => { :name => 'ruby', :priority.gt => 5 } })
  q.to_a
end

def bench_once(size)
  records = gen_records(size)
  # 正確性先驗
  a = plain_filter(records)
  b = mongory_filter_rb(records)
  raise 'mismatch (rb)' unless a == b

  GC.start
  puts "\n== size=#{size}"
  Benchmark.bm(14) do |x|
    x.report('plain_ruby') { 3.times { plain_filter(records) } }
    x.report('mongory_rb') { 3.times { mongory_filter_rb(records) } }
  end
end

[1_000, 10_000, 100_000].each { |n| bench_once(n) }

第一次執行僅作為暖機,第二次開始記錄;若希望更穩定,可將 3 次改為 10 次並計算中位數

數據(在 linux 跑,ruby 版本 2.7.8):

== size=1000
                     user     system      total        real
plain_ruby       0.001151   0.000016   0.001167 (  0.001108)
mongory_rb       0.005817   0.000000   0.005817 (  0.005817)

== size=10000
                     user     system      total        real
plain_ruby       0.008220   0.000015   0.008235 (  0.008233)
mongory_rb       0.047256   0.000000   0.047256 (  0.047257)

== size=100000
                     user     system      total        real
plain_ruby       0.090509   0.004023   0.094532 (  0.094532)
mongory_rb       0.477171   0.001838   0.479009 (  0.479030)

結論

不管在什麼情境下, Mongory-rb 都比純 Ruby filter 慢了約五倍!
而且這還是優化後的數據,優化前我記得差到 20 倍去了 :(

那時第一次把數據跑出來時,筆者其實有點失落:Mongory 明顯落後了純 Ruby 一整個數量級!
那段時間,筆者每天晚上反覆嘗試各種作法,後來把 matcher 的邏輯編譯成 Proc,讓整棵樹包成一個閉包,盡量降低 callstack 的深度;也做了調整遍歷順序等小優化

結果是有進步,但距離純 Ruby 仍然有差距

這不是否定結構化的價值,而是提醒筆者:若要達到「快且穩」,就必須往語言邊界下探,消除 Ruby 呼叫深度與分派開銷
因此,筆者決定啟動下一階段:以 C 語言實作核心(mongory-core),讓 matcher tree 的建構與匹配落在更低層的成本模型
但非本科出身的筆者從沒碰過 C,一上手就要搞這麼個小而精的項目,實在是個挑戰....
沒關係就從基礎開始,一步一步來

腳步可以慢,但絕不能停下來!

幹大事,我幹大的!

接下來 Day 9 將介紹測試先行與 Unity 的使用方式,解釋筆者如何把 C Core 切成小單元、逐步建構基礎設施與 matcher tree,並確保效能優化的同時不犧牲正確性

專案首頁(Ruby 版)


上一篇
Day 7:Explain 與 match trace:可觀測性到位
下一篇
Day 9:測試先行(Unity)與小單元驅動的開發哲學
系列文
Mongory:打造跨語言、高效能的萬用查詢引擎11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言