Computer >> Hướng Dẫn Máy Tính >  >> Lập Trình >> Ruby

Đảm bảo hiệu quả Ruby: RSpec Test cho Object Allocation

Ai cũng nói về hiệu năng Ruby gần đây, có lý do. Với tweak nhỏ code, có thể tăng performance lên 99.9%.

Nhiều bài viết về cách optimize code, nhưng làm sao đảm bảo code vẫn optimized?

Bạn không luôn cân nhắc hậu quả khi nhúng string literal thay vì frozen constant trong method gọi thường xuyên — quá dễ mất saving của optimization khi maintain code sau này.

Đây là suy nghĩ gần đây khi tôi optimize code lần 2 (hoặc 3) trong Ruby gem tại Honeybadger: "tuyệt vời quá nếu có cách đảm bảo optimization không regress?"

Regression (quay lùi) là khái niệm quen thuộc trong dev, dù không biết tên. Regression xảy ra khi bug/issue đã fix trước đây tái diễn do thay đổi sau trên cùng code. Không ai thích làm lại việc đã xong; regression như rắc bụi ngay sau khi quét nhà.

May mắn có "vũ khí bí mật": test. Dù có thực hành TDD教 điều hay không, test tuyệt vời fix bug vì chứng minh issue và solution programmatically. Test cho confidence regression không xảy ra khi code thay đổi.

Quen thuộc? Tôi cũng nghĩ vậy, khiến tôi tự hỏi: "nếu performance optimization có thể regress, tại sao không bắt regression đó bằng test?"

Nhiều tool profile performance Ruby: object allocation, memory, CPU, GC... Bao gồm ruby-prof, stackprof, allocation_tracer.

Gần đây tôi dùng `allocation_stats` profile object allocation. Giảm allocation là việc dễ, cho nhiều low-hanging fruit tuning memory và speed.

Ví dụ class Ruby cơ bản lưu Array 5 string default 'foo':

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

API AllocationStats đơn giản. Cho block để profile, in ra nơi allocate object nhiều nhất.

$ 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

Method `#to_text` (gọi trên group allocations) in bảng readable nhóm theo tiêu chí bạn yêu cầu.

Output này hay khi profile manual, nhưng mục tiêu tôi: tạo test chạy cùng unit test suite (viết RSpec). Ta thấy line 4 my_class.rb allocate 5 string, dường như không cần vì cùng giá trị. Tôi muốn scenario: "khi khởi tạo MyClass allocate dưới 6 object". RSpec trông như:

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

Với syntax này, tôi có mọi thứ cần test object allocation dưới ngưỡng cho block code (bên trong `expect`) dùng custom RSpec matcher.

Ngoài in trace, AllocationStats cung cấp method truy cập allocation qua Ruby: `#allocations` và `#new_allocations`. Dùng để xây 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` ở require đầu vì có thể không muốn include AllocationStats mọi test run (làm chậm test). Định nghĩa matcher `:allocate_under` thực hiện trace trong block `match`. Block `failure_message` quan trọng vì include output `to_text` từ AllocationStats trace ngay trong failure message! Phần còn lại cấu hình RSpec chuẩn.

Với matcher loaded, chạy scenario trước, xem 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
  --------------------------- ---------- ------- -----
  /spec/my_class_spec.rb 6 MyClass 1
  /lib/my_class.rb 3 Array 1
  /lib/my_class.rb 4 String 5
  # ./spec/my_class_spec.rb:6:in `block (3 levels) in '
  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

OK, đã chứng minh programmatically performance problem: MyClass allocate string object thừa cùng giá trị. Fix bằng cách cho giá trị vào frozen constant:

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

Fix xong, chạy test lại, watch it 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

Lần sau thay đổi `MyClass#initialize`, tôi confident không allocate quá nhiều object.

Vì profile allocation khá chậm, lý tưởng chạy on-demand thay vì luôn. Vì đã handle graceful missing `allocation_stats`, dùng Bundler tạo multiple gemfile, chỉ định gemfile qua env var `BUNDLE_GEMFILE`:

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

Option khác: dùng library như `appraisal` gem, cùng approach nhưng giải quyết gotchas Bundler. Jason Clark có presentation tuyệt vời tại Ruby on Ales tháng 3/2015; xem slide để biết thêm.

Tôi cũng nghĩ maintain test loại này tách biệt unit test là ý tưởng hay, nên tạo directory "performance" mới: unit test ở `spec/unit/`, performance suite ở `spec/performance/`.

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

Tôi vẫn refine approach profile Ruby code cho performance; hy vọng maintain performance test suite giúp cải thiện speed ngay, giữ nhanh sau này, và tạo documentation cho bản thân và người khác.