Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Ruby

Ensure Ruby Efficiency: RSpec Tests สำหรับ Object Allocation

ทุกคนพูดเรื่อง Ruby performance 近来 有 好 理由 小小 tweaks code เพิ่ม performance ได้ถึง 99.9%

มี articles เยอะเรื่อง how to optimize code แต่ทำไงให้ code คงที่ optimized?

อาจไม่คิดถึง consequences เวลา embed string literal แทน frozen constant ใน regularly called method เหมือนเกินไปที่จะ lose savings ของ optimizations เวลา maintain code ในอนาคต

ความคิดนี้มาตอน optimize code ครั้งที่ 2 (หรือ 3) ใน Ruby gem ที่ Honeybadger: "ดีใจถ้ามีวิธี ensure optimizations ไม่ regress?"

Regressions คุ้นเคยใน software development แม้ไม่รู้ชื่อ Regression = bug/issue ที่ resolved ในอดีต เกิดขึ้นอีกจาก future change ไปที่ same code ไม่มีใครชอบทำงานซ้ำ regressions 像 tracking dirt on the floor right after it's been swept

มี secret weapon: tests ไม่ว่า TDD dogmatic หรือไม่ tests awesome สำหรับ fix bugs เพราะ demonstrate issue และ solution programmatically Tests ให้ confidence regressions ไม่เกิดเวลา changes happen

คุ้นเคยไหม? ผมคิดว่า "ถ้า performance optimizations regress ทำไมไม่ catch ด้วย tests เหมือนกัน?"

มี tools profiling performance aspects ของ Ruby: object allocations, memory, CPU, GC etc. ruby-prof, stackprof, allocation_tracer

ผมใช้ allocation_stats profile object allocations ลด allocations ง่าย ได้ low-hanging fruit สำหรับ tune memory และ speed

ตัวอย่าง basic Ruby class เก็บ Array 5 strings default 'foo':

class MyClass def initialize @values = Array.new(5) 5.times { @values << 'foo' } end end 

AllocationStats API 简单 ให้ block to profile จะ print ที่ allocate objects มากที่สุด

$ ruby -r allocation_stats -r ./lib/my_class stats = AllocationStats.trace { MyClass.new } puts stats.allocations(alias_paths: true).group_by(:sourcefile, :sourceline, :class).to_text ^D sourcefile sourceline class count --------------------- ---------- ------- ----- /lib/my_class.rb 4 String 5 /lib/my_class.rb 3 Array 1 - 1 MyClass 1 

#to_text method (บน group of allocations) print human-readable table grouped by criteria ที่ขอ

Output นี้ดีตอน profile manually แต่เป้าหมายคือสร้าง test รันคู่กับ normal unit test suite (RSpec) เห็นว่า line 4 my_class.rb allocate 5 strings ซึ่ง unnecessary เพราะรู้ว่า same value อยาก scenario: "when initializing MyClass it allocates under 6 objects" ใน RSpec:

describe MyClass do context "when initializing" do specify { expect { MyClass.new }.to allocate_under(6).objects } end end 

Syntax นี้มีทุกอย่าง test object allocations น้อยกว่าตัวเลขที่กำหนด สำหรับ block of code (ใน expect block) ใช้ custom RSpec matcher

AllocationStats มี methods access allocations via Ruby รวม #allocations และ #new_allocations ใช้สร้าง matcher:

begin require 'allocation_stats' rescue LoadError puts 'Skipping AllocationStats.' end RSpec::Matchers.define :allocate_under do |expected| match do |actual| return skip('AllocationStats is not available: skipping.') unless defined?(AllocationStats) @trace = actual.is_a?(Proc) ? AllocationStats.trace(&actual) : actual @trace.new_allocations.size < expected end def objects self end def supports_block_expectations? true end def output_trace_info(trace) trace.allocations(alias_paths: true).group_by(:sourcefile, :sourceline, :class).to_text end failure_message do |actual| "expected under #{ expected } objects to be allocated; got #{ @trace.new_allocations.size }:\n\n" << output_trace_info(@trace) end description do "allocates under #{ expected } objects" end end 

Rescue LoadError ใน initial require เพราะอาจไม่อยาก include AllocationStats ทุก test run (มันช้า tests) Define :allocate_under matcher trace ใน match block failure_message สำคัญเพราะ include to_text output จาก AllocationStats trace ใน failure message! ส่วนที่เหลือ standard RSpec configuration

Matcher loaded รัน scenario ก่อนหน้า ดูมัน fail:

$ rspec spec/my_class_spec.rb MyClass when initializing should allocates under 6 objects (FAILED - 1) Failures: 1) MyClass when initializing should allocates under 6 objects Failure/Error: expect { MyClass.new }.to allocate_under(6).objects expected under 6 objects to be allocated; got 7: sourcefile sourceline class count --------------------------- ---------- ------- ----- <PWD>/spec/my_class_spec.rb 6 MyClass 1 <PWD>/lib/my_class.rb 3 Array 1 <PWD>/lib/my_class.rb 4 String 5 # ./spec/my_class_spec.rb:6:in `block (3 levels) in <top (required)>' Finished in 0.15352 seconds (files took 0.22293 seconds to load) 1 example, 1 failure Failed examples: rspec ./spec/my_class_spec.rb:5 # MyClass when initializing should allocates under 6 objects 

Programmatically demonstrated performance problem: MyClass allocate extra string objects ค่าเดียวกัน Fix โดยใส่ values ใน frozen constant:

class MyClass DEFAULT = 'foo'.freeze def initialize @values = Array.new(5) 5.times { @values << DEFAULT } end end 

Fix แล้ว รัน test อีกครั้ง ดูมัน pass:

$ rspec spec/my_class_spec.rb MyClass when initializing should allocates under 6 objects Finished in 0.14952 seconds (files took 0.22056 seconds to load) 1 example, 0 failures 

ครั้งหน้าเปลี่ยน MyClass#initialize confident ว่าไม่ allocate objects มากเกินไป

เพราะ profiling allocations ช้า ควรรัน on-demand แทน all the time เนื่องจาก handle allocation_stats missing gracefully ใช้ Bundler สร้าง multiple gemfiles ระบุ gemfile ด้วย BUNDLE_GEMFILE env variable:

$ BUNDLE_GEMFILE=with_performance.gemfile bundle exec rspec spec/ $ BUNDLE_GEMFILE=without_performance.gemfile bundle exec rspec spec/ 

อีกออปชัน appraisal gem ใช้ approach เดียวกันแก้ Bundler gotchas Jason Clark present excellent ที่ Ruby on Ales March 2015 ดู slides เรียนเพิ่ม

คิดว่า maintain tests ประเภทนี้แยกจาก unit tests ดี จะสร้าง "performance" directory unit tests ใน spec/unit/ performance suite ใน spec/performance/:

spec/ |-- spec_helper.rb |-- unit/ |-- features/ |-- performance/ 

ยัง refine approach profiling Ruby code for performance หวังว่า maintain performance test suite ช่วย improve speed ตอนนี้ คงความเร็วอนาคต และสร้าง documentation ให้ตัวเองและคนอื่น