Computer >> 컴퓨터 >  >> 프로그램 작성 >> Ruby

RSpec으로 객체 할당 테스트하기

모두가 최근에 Ruby 성능에 대해 이야기하고 있습니다. 그럴만한 이유가 있습니다. 코드를 약간만 수정하면 성능을 최대 99.9%까지 높일 수 있습니다.

방법에 대한 많은 기사가 있습니다. 코드를 최적화할 수 있지만 코드가 남아 있는지 최적화?

정기적으로 호출되는 메서드에 고정된 상수가 아닌 문자열 리터럴을 포함할 때의 결과를 항상 고려하지는 않을 수 있습니다. 나중에 코드를 유지 관리할 때 절약한 최적화를 잃는 것은 너무 쉽습니다.

최근 Honeybadger의 Ruby gem에서 일부 코드를 두 번째(또는 세 번째) 최적화하면서 다음과 같은 생각을 했습니다. "이러한 최적화가 퇴행하지 ?"

회귀는 이름이 아니더라도 소프트웨어 개발에서 우리 대부분에게 익숙한 것입니다. 회귀는 동일한 코드에 대한 향후 변경으로 인해 과거에 해결된 버그 또는 문제가 다시 발생할 때 발생합니다. 같은 일을 한 번 이상 하는 것을 좋아하는 사람은 없습니다. 회귀는 바닥을 쓸고 난 직후 바닥의 흙을 추적하는 것과 같습니다.

운 좋게도 우리에게는 비밀 무기가 있습니다. 바로 테스트입니다. 독단적인 TDD를 연습하든 하지 않든 테스트는 굉장합니다. 버그 수정은 문제와 솔루션을 프로그래밍 방식으로 보여주기 때문입니다. 테스트를 통해 변경 사항이 발생하면 회귀가 발생하지 않을 것이라는 확신을 갖게 됩니다.

익숙한 소리? 나도 그렇게 생각했는데, "성능 최적화가 퇴행할 수 있다면 왜 테스트에서도 퇴행을 포착할 수 없습니까?" 하는 생각이 들었습니다.

개체 할당, 메모리, CPU, 가비지 수집 등을 포함하여 Ruby의 다양한 성능 측면을 프로파일링하기 위한 훌륭한 도구가 많이 있습니다. 이러한 도구에는 ruby-prof, stackprof 및 allocation_tracer가 포함됩니다.

저는 최근에 allocation_stats를 사용하여 개체 할당을 프로파일링했습니다. 할당을 줄이는 것은 달성하기 매우 쉬운 작업이며, 메모리 소비와 속도를 조정하는 데 많은 도움이 됩니다.

예를 들어, 다음은 기본적으로 'foo'로 지정되는 5개의 문자열 배열을 저장하는 기본 Ruby 클래스입니다.

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

AllocationStats API는 간단합니다. 프로필에 블록을 지정하면 가장 많은 개체가 할당된 위치가 인쇄됩니다.

$ 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 메서드(할당 그룹에서 호출됨)는 요청하는 기준에 따라 그룹화되어 사람이 읽을 수 있는 멋진 테이블을 출력합니다.

이 출력은 수동으로 프로파일링할 때 훌륭하지만 내 목표는 일반 단위 테스트 모음(RSpec으로 작성됨)과 함께 실행할 수 있는 테스트를 만드는 것이었습니다. my_class.rb의 4행에서 5개의 문자열이 할당되고 있는 것을 볼 수 있습니다. , 모두 동일한 값을 포함한다는 것을 알고 있기 때문에 불필요해 보입니다. 내 시나리오가 "MyClass를 초기화할 때 6개 개체 아래에 할당합니다"와 같은 내용을 읽고 싶었습니다. RSpec에서 이것은 다음과 같습니다:

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

이 구문을 사용하면 설명된 코드 블록(expect 블록) 사용자 지정 RSpec 매처를 사용합니다.

추적 결과를 인쇄하는 것 외에도 AllocationStats는 #allocations를 포함하여 Ruby를 통해 할당에 액세스하는 몇 가지 방법을 제공합니다. 및 #new_allocations . 다음은 내 매터를 빌드하는 데 사용한 것입니다.

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

LoadError를 구하고 있습니다 모든 테스트 실행에 AllocationStats를 포함하고 싶지 않을 수 있기 때문에 초기 require 문에 포함합니다(테스트 속도가 느려지는 경향이 있음). 그런 다음 :allocate_under를 정의합니다. match 내부에서 추적을 수행하는 matcher 차단하다. failure_message 블록은 to_text를 포함하기 때문에 중요합니다. AllocationStats 추적의 출력 내 실패 메시지 바로 내부 ! 나머지 매처는 대부분 표준 RSpec 구성입니다.

내 매처가 로드되면 이제 이전의 시나리오를 실행하고 실패하는 것을 볼 수 있습니다.

$ 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

자, 그래서 저는 프로그래밍 방식으로 성능 문제를 시연했습니다. 즉, MyClass가 동일한 값을 가진 추가 문자열 개체를 할당한다는 것입니다. 이러한 값을 고정된 상수에 던져 문제를 해결해 보겠습니다.

class MyClass
  DEFAULT = 'foo'.freeze

  def initialize
    @values = Array.new(5)
    5.times { @values << DEFAULT }
  end
end

이제 문제를 해결했으므로 테스트를 다시 실행하고 통과하는지 확인하겠습니다.

$ 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를 변경할 때 방법을 사용하면 너무 많은 개체를 할당하고 있지 않다고 확신할 수 있습니다.

프로파일링 할당은 상대적으로 느릴 수 있으므로 항상 실행하는 것보다 주문형으로 실행하는 것이 이상적입니다. 이미 누락된 할당 통계를 정상적으로 처리하고 있으므로 Bundler를 사용하여 여러 gemfile을 만든 다음 BUNDLE_GEMFILE 환경 변수와 함께 사용할 gemfile을 지정할 수 있습니다.

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

또 다른 옵션은 동일한 접근 방식을 취하고 일부 Bundler 문제를 해결하는 평가 보석과 같은 라이브러리를 사용하는 것입니다. Jason Clark은 2015년 3월 Ruby on Ales에서 이 작업을 수행하는 방법에 대한 훌륭한 프레젠테이션을 했습니다. 자세한 내용은 그의 슬라이드를 확인하십시오.

나는 또한 이러한 유형의 테스트를 일반적인 단위 테스트와 별도로 유지하는 것이 좋은 생각이라고 생각합니다. 따라서 새 "성능" 디렉토리를 만들어 단위 테스트 모음이 spec/unit/에 있고 성능 모음이 spec에 상주하도록 하겠습니다. /성능/:

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

성능을 위해 Ruby 코드를 프로파일링하는 접근 방식을 계속 개선하고 있습니다. 성능 테스트 모음을 유지하면 현재 코드의 속도를 개선하고 미래에 빠르게 유지하며 나와 다른 사람을 위한 문서를 만드는 데 도움이 되기를 바랍니다.