ตอน Develop Applications เรามักมี Methods ที่รันช้า อาจต้อง Query Database หรือ Hit External Service ทั้งคู่ทำให้ช้า เราอาจเรียก Method ทุกครั้งที่ต้องการ Data และยอมรับ Overhead แต่ถ้า Performance เป็น Concern เรามี Options
หนึ่งคือ Assign Data ไป Variable และ Re-use ซึ่งเร่งกระบวนการ แต่ Manual Manage Variable นั้นอาจ Tedious (น่าเบื่อ/ยุ่งยาก) ได้เร็ว
แต่ถ้า Method ที่ทำ "Slow Work" นั้นจัดการ Variable ให้เองล่ะ? จะทำให้เราเรียก Method แบบเดิม แต่ Method จะ Save และ Re-use Data เอง นี่คือ Memoization อย่างแน่นอน
ง่ายๆ Memoization คือการเก็บ Return Value ของ Method ไว้ เพื่อไม่ต้อง Recompute ทุกครั้ง เหมือน Caching ทุกประเภท คุณ Trade Memory สำหรับ Time (คุณยอม Memory สำหรับเก็บ Value แต่ประหยัด Time ที่ต้อง Process Method)
วิธี Memoize Value
Ruby มี Idiom สะอาดสำหรับ Memoize Values ด้วย Or-equals Operator: ||= ใช้ Logical OR (||) ระหว่าง Left และ Right Values แล้ว Assign ผลลัพธ์ให้ Variable ด้านซ้าย ในการทำงาน:
value ||= expensive_method(123) # logically equivalent to: value = (value || expensive_method(123)) Memoization ทำงานอย่างไร
เพื่อเข้าใจ ต้องเข้าใจสอง Concepts: "Falsey" Values และ Lazy Evaluation เริ่มจาก Truthy-Falsey
Truthy และ Falsey
Ruby (เหมือนภาษาส่วนใหญ่) มี Built-in Keywords สำหรับ Boolean true และ false ทำงานตรงตามคาด:
if true # เรารันนี้เสมอ end if false # นี้ไม่เคยรัน end อย่างไรก็ตาม Ruby (และภาษาอื่นๆ มาก) มี Concept ของ "Truthy" และ "Falsey" Values หมายถึง Values สามารถถูก Treat "as if" พวกมันเป็น true หรือ false ใน Ruby เพียง nil และ false เป็น Falsey ค่าอื่นทั้งหมด (รวม Zero) ถูก Treat เป็น true (Note: ภาษาอื่นตัดสินใจต่างกัน เช่น C ทำให้ Zero เป็น false) Re-use ตัวอย่างข้างบน เราสามารถเขียนได้อีก:
value = "abc123" # a string if value # เรารันนี้เสมอ end value = nil if value # นี้ไม่เคยรัน end Lazy Evaluation
Lazy Evaluation เป็น Optimization Form ที่พบบ่อยใน Programming Languages มัน Allow โปรแกรม Skip Operations ที่ไม่จำเป็น
Logical OR Operator (||) คืน True ถ้า Left หรือ Right-hand Side เป็น True หมายถึงถ้า Left-hand Argument เป็น True ไม่มีประโยชน์ Evaluate Right-hand Side เพราะเรารู้แล้วว่าผลลัพธ์จะเป็น True ถ้าเรา Implement เองอาจเป็นแบบนี้:
def logical_or (lhs, rhs) return lhs if lhs rhs end ถ้า lhs และ rhs เป็น Functions (เช่น Lambdas) คุณจะเห็น rhs จะ Execute ก็ต่อเมื่อ lhs เป็น Falsey
Or-Equals
รวม Truthy-falsey Values และ Lazy Evaluation ทำให้เห็นว่า Operator ||= ทำอะไร:
value # defaults to nil value ||= "test" value ||= "blah" puts value => test เริ่มต้น value เป็น nil เพราะไม่ได้ Initialize ต่อไปเจอ ||= ตัวแรก value เป็น Falsey ตอนนี้ Evaluate Right-hand Side ("test") Assign ผลลัพธ์ให้ value ตอนนี้เจอ ||= ตัวที่สอง แตคราวนี้ value เป็น Truthy เพราะมีค่า "test" เรา Skip Evaluation Right-hand Side และดำเนินต่อกับ value คงเดิม
ตัดสินใจเมื่อไหร่ใช้ Memoization
ตอนใช้ Memoization มีคำถามต้องถามตัวเอง: Value ถูก Access บ่อยแค่ไหน? อะไรทำให้มันเปลี่ยน? มันเปลี่ยนบ่อยแค่ไหน?
ถ้า Value Accessed แค่ครั้งเดียว Caching ไม่ค่อย Useful ยิ่ง Access บ่อย ยิ่งได้ Benefit มากจาก Caching
เรื่อง What Causes It To Change เราต้องดู Values ที่ใช้ใน Method มันรับ Arguments ไหม? ถ้าใช่ Memoization อาจต้องคำนึงถึงนี้ ผมชอบใช้ Memoist Gem เพราะมัน Handle Arguments ให้
สุดท้าย ต้องพิจารณา Value เปลี่ยนบ่อยแค่ไหน มี Instance Variables ทำให้เปลี่ยนไหม? ต้อง Clear Cached Value ตอนพวกมันเปลี่ยนไหม? ควร Cache ที่ Object Level หรือ Class Level?
ตอบคำถามเหล่านี้ มาดูตัวอย่างง่ายและ Step Through Decisions:
class ProfitLossReport def initialize(title, expenses, invoices) @expenses = expenses @invoices = invoices @title = title end def title "#{@title} #{Time.current}" end def cost @expenses.sum(:amount) end def revenue @invoices.sum(:amount) end def profit revenue - cost end def average_profit(months) profit / months.to_f end end Calling Code ไม่แสดง แต่เดาได้ว่า Method title เรียกแค่ครั้งเดียว ใช้ Time.current ด้วย Memoize อาจทำให้ Value Stale ทันที
Methods revenue และ cost ถูก Hit หลายครั้งแม้ภายใน Class นี้ ถ้า Hit Database พวกมันเป็น Prime Candidates สำหรับ Memoize ถ้า Performance เป็น Issue สมมติเรา Memoize พวกมัน profit ไม่ควร Memoize ไม่งั้นเราแค่เพิ่ม Caching บน Caching สำหรับ Gains น้อย
สุดท้าย average_profit Value ที่นี่ Rely บน Argument Memoizing ต้องคำนึงถึง Argument สำหรับ Case เรียบง่าย مثل revenue เราทำได้แค่:
def revenue @revenue ||= @invoices.sum(:amount) end แต่ average_profit ต้อง Value ต่างกันสำหรับ Argument แต่ละค่า เราอาจใช้ Memoist แต่เพื่อ Clarity เรา Roll Our Own Solution:
def average_profit(months) @average_profit ||= {} @average_profit[months] ||= profit / months.to_f end ที่นี่เราใช้ Hash Track Computed Values แรก Ensure @average_profit Initialized แล้วใช้ Argument ที่ส่งมาเป็น Hash Key
Memoize ที่ Class Level หรือ Instance Level
ส่วนใหญ่ Memoization ทำที่ Instance Level หมายถึงใช้ Instance Variable เก็บ Computed Value หมายถึงทุกครั้งที่สร้าง Instance ใหม่ มันไม่ได้ Benefit จาก "Cached" Value ตัวอย่างง่าย:
class MemoizedDemo def value @value ||= computed_value end def computed_value puts "Crunching Numbers" rand(100) end end ใช้ Object นี้ ดูผล:
demo = MemoizedDemo.new => #<MemoizedDemo:0x00007f95e5d9d398> demo.value Crunching Numbers => 19 demo.value => 19 MemoizedDemo.new.value Crunching Numbers => 93 เราเปลี่ยนได้ด้วย Class-level Variable (@@) สำหรับ Memoized Value:
def value @@value ||= computed_value end ผลลัพธ์กลายเป็น:
demo = MemoizedDemo.new => #<MemoizedDemo:0x00007f95e5d9d398> demo.value Crunching Numbers => 60 demo.value => 60 MemoizedDemo.new.value => 60 คุณอาจไม่ต้องการ Class-level Memoization บ่อย แต่เป็น Option อยู่ ถ้าต้องการ Cache ที่ Level นี้ คุ้มค่าที่จะดู Caching ด้วย External Store เช่น Redis หรือ Memcached
Common Memoization Use Cases ใน Rails Applications
ใน Rails Apps Use-case พบบ่อยที่สุดของ Memoization คือลด Database Calls โดยเฉพาะ Value ที่ไม่เปลี่ยนภายใน Single Request "Finder" Methods สำหรับ Lookup Records ใน Controllers ตัวอย่างดี:
def current_user @current_user ||= User.find(params[:user_id]) end อีกที่ทั่วไปคือถ้าใช้ Decorator/Presenter/View-Model Architecture สำหรับ Render Views Methods ใน Objects เหล่านี้มักมี Candidates ดีสำหรับ Memoization เพราะ Persist แค่ Life ของ Request Data ปกติไม่ Mutate และบาง Methods ถูก Hit หลายครั้งตอน Render Views
Memoization Gotchas
Gotcha ใหญ่ที่สุดคือ Memoize Things เมื่อไม่จำเป็นจริงๆ Things เช่น String Interpolation ดูเหมือน Candidates ง่ายสำหรับ Memoization แต่ในความเป็นจริง ไม่ค่อย Cause Noticeable Impact บน Site Performance (เว้นแต่คุณใช้ Exceptionally Large Strings หรือ String Manipulation มากมาย) เช่น:
def title # memoization here is not going to have much of an impact on our performance @title ||= "#{@object.published_at} - #{@object.title}" end อีกอย่าง Watch Out สำหรับ Cache Invalidation โดยเฉพาะถ้า Memoized Value Depends on Object State วิธีช่วย Prevent คือ Cache ที่ Lowest Level ที่ทำได้ แทนที่จะ Cache Method Compute a + b อาจดีกว่า Cache a และ b Methods แยกกัน
# แทนนี้ def profit # ใครเรียก 'revenue' หรือ 'losses' ไม่ Benefit จาก Caching ที่นี่ # และถ้า 'revenue' หรือ 'losses' เปลี่ยน เราจะจำได้ไหม Update Profit? @profit ||= (revenue - losses) end # ลองนี้ def profit # ไม่ Cache แล้ว แต่ Subtraction เป็น Fast Calculation revenue - losses end def revenue @revenue ||= Invoice.all.sum(:amount) end def losses @losses ||= Purchase.all.sum(:amount) end Gotcha สุดท้ายมาจาก Lazy Evaluation - คุณต้อง Custom เล็กน้อยถ้าต้อง Memoize Falsey Value (เช่น nil หรือ false) เพราะ Idiom ||= จะ Execute Right-hand Side เสมอถ้า Saved Value เป็น Falsey ในประสบการณ์ผม ไม่ค่อยต้อง Cache ค่าเหล่านี้ แต่ถ้าต้อง อาจเพิ่ม Boolean Flag บอกว่า Computed แล้ว หรือใช้ Caching Mechanism อื่น
def last_post # ถ้า User ไม่มี Posts เราจะ Hit Database ทุกครั้งที่ Method นี้ถูกเรียก @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first end # Workaround ง่ายๆ อาจทำแบบนี้: def last_post return @last_post if @last_post_checked @last_post_checked = true @last_post ||= Post.where(user: current_user).order_by(created_at: :desc).first end เมื่อ Memoization ไม่พอ
Memoization เป็น Cheap และ Effective Way 改善 Performance ในส่วนของ Application แต่ไม่ปราศจาก Drawbacks ใหญ่หนึ่งคือ Persistence สำหรับ Instance-level Memoization ปกติ Value เก็บได้แค่ Object นั้นๆ ทำให้ Memoization เหมาะสำหรับ Save Values ตลอด Life ของ Web Request แต่ไม่ให้ Full Benefits ของ Caching ถ้าคุณมี Values เหมือนกันสำหรับ Multiple Requests และ Re-compute ทุกครั้ง
Class-level Memoization อาจช่วย แต่ยากต่อ Manage Cache Invalidation เห็นด้วยไหม นاهิกจากถ้า Server Reboot Cached Values จะหาย และไม่ Share ระหว่าง Multiple Web Servers ได้
ใน Issue ถัดไปของซีรีส์ Caching เราจะดู Solution ของ Rails สำหรับปัญหาเหล่านี้ - Low-level Caching อนุญาตให้ Cache Values ไป External Store ที่ Share ระหว่าง Servers และ Manage Cache Invalidation ด้วย Expiry Timeouts และ Dynamic Cache Keys