iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
佛心分享-SideProject30

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

Day 18:Benchmark shock:為什麼還是比 plain Ruby 慢?

  • 分享至 

  • xImage
  •  

這一天,是筆者在 Mongory C 擴充完成後的第一波震撼教育:既然都進了 C,為什麼還是比 plain Ruby 慢?數據擺在眼前,在初版 bridge 完成後,整體大約是純 Ruby 的三倍時間,雖然略優於原先的 Mongory-rb,但離「追平甚至超車 plain Ruby」的目標還差一截。這篇把「怎麼量」、「量到什麼」、「為什麼慢」與「先做哪些修正」講清楚,並為 Day 19、Day 20 的關鍵優化鋪路。

問題定義與基準線

  • 期待:C Core 介入後,應在資料量放大時享有更好的時間複雜度與常數因子,至少追平 plain Ruby,理想情況下能在複雜條件下取得優勢。
  • 觀察:初版 CMatcher/CQueryBuilder 測試,花費時間約為 plain Ruby 的 3 倍,相較原 Mongory-rb(純 Ruby)有進步,但仍不滿意。
  • 目標:找出主要瓶頸,先以最低風險的結構性修正把效能拉回正軌,再逐步打開更激進的路線(如 shallow wrap 的 O(1) 取值)。

量測方法(Methodology)

筆者採「可重現」與「可對照」為原則,拆成兩種查詢形態:

  1. 簡單條件(Simple)
  • 例:age >= 18
  • 用途:確認基本欄位比較的最低成本,避免被複合結構干擾。
  1. 複合條件(Complex)
  • 例:age >= 18 OR status == 'active'
  • 用途:評估 composite/field/compare 的組合開銷,靠近實務情境。

資料集規模

  • 20、1k、10k、100k(以漸進觀察量變 → 質變)。
  • 每輪跑 5 次,取輸出與異常檢查(結果筆數一致)。

GC 與分配觀測(可選)

  • 暫時關閉自動 GC,記錄「配置/釋放/活物件」指標,確認不必要的配置峰值。

快速示例(與 repo 內 examples/benchmark.rb 思路一致,略作化繁為簡)

records = Array.new(100_000) do
  { 'age' => [nil, rand(1..100)].sample, 'status' => %w[active inactive].sample }
end

# Plain Ruby
Benchmark.measure { records.select { |r| r['age'].is_a?(Numeric) && r['age'] >= 18 } }

# Mongory (Ruby)
builder = records.mongory.where(:age.gte => 18)
Benchmark.measure { builder.to_a }

# Mongory::CMatcher
matcher = Mongory::CMatcher.new(:age.gte => 18)
Benchmark.measure { records.select { |r| matcher.match?(r) } }

# Mongory::CQueryBuilder(C bridge builder)
builder = records.mongory.c.where(:age.gte => 18)
Benchmark.measure { builder.to_a }

說明:

  • 以上各路線都會加上結果一致性檢查,避免以「錯誤更快」誤判。
  • 在複合條件情境會改用 or({ :age.gte => 18 }, { status: 'active' }) 做相同對照。

量測結果(現象)

  • 初版 C bridge(CMatcher/CQueryBuilder)在「簡單條件」與「複合條件」下,整體時間約為 plain Ruby 的 3 倍上下
  • 與純 Ruby 版 Mongory 相比已有進步,但離目標(追平甚至超車)仍明顯不足
  • 不同資料量下,趨勢一致:資料量越大,差距越明顯。

提醒:數據會因機器、Ruby 版本、資料分佈而異,此處重點在「相對關係」與「趨勢」。

為什麼還慢:初步定位(High-level Diagnosis)

將可疑來源分為兩層:橋接開銷與資料結構開銷。

  1. 橋接開銷(Bridge)
  • Pool reallocate/重複配置:匹配迴圈中若頻繁新配記憶體,C 優勢會被 malloc/free 抵銷。
  • 鍵(key)轉換與 Ruby 對象震盪:每次取值若把 C char 轉 Ruby String/Symbol,會造成多餘配置與 GC 壓力。
  • 轉換策略偏重:每輪比對資料都做淺層 deep 轉換,造成 O(n) 複製成本。
    • 淺層 deep 轉換:聽起來很矛盾,但其實就是把 hash/array 第一層的 key/index 下的資料轉成 mongory_value:pointer 型態,再深的資料型態就不關心
    • 思路是讓 matcher 在有限的 condition 需要讀取某些 欄位/index 時,才呼叫 converter 把 mongory_value:pointer 轉成可比對的 primitive value
  1. 資料結構開銷(Core)
  • 欄位取值走一般化路徑,未針對 Ruby Hash/Array 的跨界取值最佳化,導致每次取值都經過中介節點與分派成本。

結論:在筆者的量測中,「重複配置」與「鍵轉換」是最先該下手的兩個點,能以低風險改動換回顯著收益,更進一步的結構性提升(O(1) 取值)會在 Day 20 展開。

實驗與修正(Experiments & Fixes)

先做兩個「低風險、可回退」的修正:

  • 修正 A:導入 pool reset(而非每輪重配)

    • 作法:在匹配完一輪資料後呼叫 scratch_pool->reset(scratch_pool),重用已分配的 chunk,避免反覆 malloc/free。
    • 風險:須確保 shallow 值不外流到下一輪,以生命週期檢查清單保障(Day 17 建立)。
  • 修正 B:Key Cache Map(字串/符號緩存)

    • 作法:在 build 期將常用鍵緩存為 Ruby 端的 String/Symbol,配合 mark_list 確保 GC 可見,取值時不再重複生成臨時字串/符號。
    • 風險:需確保緩存生命週期與 matcher pool 同步,避免懸掛指標或過早釋放。

觀察到的效果(相對趨勢)

  • 簡單條件:時間顯著下降,複合條件:因 Field/Composite 重複取值多,下降更明顯。
  • 分配曲線更平穩,Net alive 成長趨勢消失或放緩。
  • 仍未全面追平 plain Ruby,但差距縮小,驗證方向正確。

為何比 plain Ruby 慢(先交代、細節留到 Day 19/20)

  • 在資料仍停留於 Ruby 世界時,plain Ruby 擁有最短的資料路徑與最少的橋接層
  • C 介入若沒有把「配置模型」與「取值路徑」優化到位,會多出跨界轉換與記憶體管理成本
  • Day 19 會把「pool reset 與 key cache map」的數據與回歸驗證講完整
  • Day 20 會揭示決勝點「Shallow wrap」:把 Hash/Array 的取值改成 O(1) 直通 Ruby C API,最終達到追平甚至超車。

如何在讀者環境重現(一步步)

  1. 準備資料集(10k/100k)
records = Array.new(100_000) { { 'age' => rand(1..100), 'status' => %w[active inactive].sample } }
  1. 建立三組對照(Plain Ruby/Mongory Ruby/Mongory C)
# Plain Ruby
Benchmark.measure { records.select { |r| r['age'].is_a?(Numeric) && r['age'] >= 18 } }

# Mongory Ruby
builder = records.mongory.where(:age.gte => 18)
Benchmark.measure { builder.to_a }

# Mongory C
builder = records.mongory.c.where(:age.gte => 18)
Benchmark.measure { builder.to_a }
  1. 確認結果一致(避免「錯更快」)
plain = records.count { |r| r['age'].is_a?(Numeric) && r['age'] >= 18 }
mongo = records.mongory.where(:age.gte => 18).count
cmong = records.mongory.c.where(:age.gte => 18).count
raise 'count mismatch' unless plain == mongo && mongo == cmong
  1. 觀察 GC 指標(可選)
def gc_probe
  GC.disable
  before = GC.stat
  yield
  GC.start
  after = GC.stat
  GC.enable
  puts({
    created: after[:total_allocated_objects] - before[:total_allocated_objects],
    freed:   after[:total_freed_objects] - before[:total_freed_objects],
    alive:   after[:heap_live_slots] - before[:heap_live_slots]
  })
end

小結(現況與方向)

  • 初版 C 介面未經優化時,慢於 plain Ruby(約 3 倍)
  • 透過「pool reset」與「key cache map」,已能顯著收斂差距,驗證優化方向正確
  • 下一步將以數據化的方式呈現兩者的成效與回歸驗證
  • 終局之戰是結構性變更「Shallow wrap(O(1) 取值)」:在不 materialize 整棵資料的前提下,讓 C 直通 Ruby 的 Hash/Array 取值,降低到常數時間與常數配置。

下一篇預告

  • Day 19:關鍵優化 1——pool reset、key cache map 的成效
    • 展示優化前後的時間/配置數據
    • 說明風險控管與回退策略
    • 以回歸測試確保正確性

專案首頁(Ruby 版)


上一篇
Day 17:GC × memory pool 生命週期管理
下一篇
Day 19:關鍵優化 1:pool reset、key cache map 的成效
系列文
Mongory:打造跨語言、高效能的萬用查詢引擎25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言