Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

루비 리파인먼트(Refinement) 성능 벤치마크: 정말 느릴까?

배경 지식 없이 구글에서 '루비 리파인먼트(Ruby refinements)'를 검색해 보면, 리파인먼트가 느리다는 인상을 받기 쉽습니다.

사실 처음 제안되었던 설계대로라면 리파인먼트는 실제로 느렸을 겁니다. 메서드 탐색(method lookup) 같은 작업을 인터프리터가 최적화하는 것 자체가 불가능해졌을 테니까요.

그러나 실제로 구현된 리파인먼트는 초기 제안보다 훨씬 제한적인 형태입니다. 그래서 오늘날 루비에 존재하는 리파인먼트를 대상으로 일련의 벤치마크를 직접 돌려 보면 재미있겠다고 생각했습니다.

TL;DR — 결론부터 말하면

리파인먼트는 느리지 않습니다. 적어도 "일반적인" 메서드보다 느리지는 않아 보입니다.

테스트용 더미 코드

이번에는 메서드 호출을 벤치마킹할 예정이므로, 비교 대상이 될 몇 가지 메서드가 필요합니다.

아주 단순한 메서드 두 가지 버전을 만들어 보겠습니다. 하나는 평범한 "일반" 메서드고, 다른 하나는 리파인먼트 안에 정의된 메서드입니다.

# "더미 로드"로 사용할 셔러그(shrug)를 만듭니다.
# shrug 1개 == "¯\_(ツ)_/¯"
# shrug 2개 == "¯\_(ツ)_/¯¯\_(ツ)_/¯"
# ... 이런 식입니다.

SHRUG = "¯\_(ツ)_/¯"

# 셔러그를 생성하는 리파인먼트를 정의합니다
module Shruggable
  refine Fixnum do
    def shrugs
      SHRUG * self
    end
  end
end

# ...그리고 같은 역할을 하는 일반 메서드도 정의합니다
def integer_to_shrugs(n)
  SHRUG * n
end

리파인먼트는 직접 호출할 수 없고 반드시 using 문으로 활성화해야 합니다. 그래서 완전히 동일하게 동작하는 클래스 두 개를 준비하겠습니다. 하나는 리파인먼트를 사용하고, 다른 하나는 사용하지 않습니다.

class TestUsing
  using Shruggable
  def noop
  end

  def shrug
    10.shrugs
  end
end

class TestWithoutUsing
  def noop
  end

  def shrug
    integer_to_shrugs(10)
  end
end

벤치마크 결과

검증하고 싶었던 것은 두 가지입니다. 리파인먼트를 사용하는 클래스의 객체 생성이 더 느린지, 그리고 리파인먼트로 추가된 메서드 호출이 더 느린지였습니다.

모든 벤치마크는 macOS(OSX El Capitan) 환경에서 MRI 2.2.2 기준으로 실행했습니다.

객체 생성 속도

using 키워드 때문에 클래스 초기화가 느려질까요? 아닙니다.

Benchmark.ips do |bm|
  bm.report("class initialization") { TestUsing.new }
  bm.report("class initialization WITH using") { TestWithoutUsing.new }
  bm.compare!
end

# Calculating -------------------------------------
# class initialization   142.929k i/100ms
# class initialization WITH using
#                        145.323k i/100ms
# -------------------------------------------------
# class initialization      5.564M (± 8.3%) i/s -     27.728M
# class initialization WITH using
#                           5.619M (± 7.4%) i/s -     28.047M
# Comparison:
# class initialization WITH using:  5618601.3 i/s
# class initialization:  5564116.5 i/s - 1.01x slower

메서드 호출 속도

리파인먼트가 "평범한" 메서드 탐색 속도에 영향을 줄까요? 아닙니다.

Benchmark.ips do |bm|
  bm.report("run method") { TestUsing.new.noop }
  bm.report("run method in class WITH using") { TestWithoutUsing.new.noop }
  bm.compare!
end

# Calculating -------------------------------------
#           run method   141.905k i/100ms
# run method in class WITH using
#                        144.435k i/100ms
# -------------------------------------------------
#           run method      5.010M (± 6.4%) i/s -     24.975M
# run method in class WITH using
#                           5.086M (± 5.3%) i/s -     25.421M
# Comparison:
# run method in class WITH using:  5086262.3 i/s
#           run method:  5010273.6 i/s - 1.02x slower

그렇다면 리파인먼트로 추가한 메서드를 호출하는 것이 동일한 역할의 "일반" 메서드보다 느릴까요? 역시 아닙니다.

Benchmark.ips do |bm|
  bm.report("shrug") { TestUsing.new.shrug }
  bm.report("shrug via refinement") { TestWithoutUsing.new.shrug }
  bm.compare!
end

# Calculating -------------------------------------
#                shrug    96.089k i/100ms
# shrug via refinement    95.559k i/100ms
# -------------------------------------------------
#                shrug      1.825M (± 9.3%) i/s -      9.128M
# shrug via refinement      1.929M (± 6.2%) i/s -      9.651M

# Comparison:
# shrug via refinement:  1928841.5 i/s
#                shrug:  1825069.4 i/s - 1.06x slower

억지로 느리게 만들어 보기

그렇다면 대조군보다 리파인먼트 쪽을 더 느리게 만들 수 있는 방법이 있을까요? ¯\_(ツ)_/¯

# `using` 키워드를 반복해서 평가하면 성능에 영향이 있을까요? 아주 약간 있긴 합니다.
# 이건 사실 공정하지 않은 테스트지만, 그래도 "어떤 경우에는" 리파인먼트가
# 느려지는 모습을 꼭 보고 싶었습니다 :)
Benchmark.ips do |bm|
  bm.report("inline shrug") { integer_to_shrugs(10) }
  bm.report("inline shrug via refinement") do
    using Shruggable
    10.shrugs
  end
  bm.compare!
end

# Calculating -------------------------------------
#         inline shrug   100.460k i/100ms
# inline shrug via refinement
#                         72.131k i/100ms
# -------------------------------------------------
#         inline shrug      2.507M (± 5.2%) i/s -     12.557M
# inline shrug via refinement
#                           1.498M (± 4.3%) i/s -      7.502M

# Comparison:
#         inline shrug:  2506663.9 i/s
# inline shrug via refinement:  1497747.6 i/s - 1.67x slower

반복문 안에서 매번 using을 다시 평가하는 이 극단적인 케이스에서만 약 1.67배 느려졌습니다. 하지만 이는 실제 코드에서 거의 발생하지 않는 비정상적인 패턴이라, 일반적인 사용 시나리오에서는 성능 저하를 걱정할 필요가 없다는 결론에 변함이 없습니다.

전체 코드

직접 벤치마크를 실행해 보고 싶으신 분들을 위해 전체 코드를 공유합니다.

require 'benchmark/ips'


# "더미 로드"로 사용할 셔러그(shrug)를 만듭니다.
# shrug 1개 == "¯\_(ツ)_/¯"
# shrug 2개 == "¯\_(ツ)_/¯¯\_(ツ)_/¯"
# ... 이런 식입니다.

SHRUG = "¯\_(ツ)_/¯"

# 셔러그를 생성하는 리파인먼트를 정의합니다
module Shruggable
  refine Fixnum do
    def shrugs
      SHRUG * self
    end
  end
end

# ...그리고 같은 역할을 하는 일반 메서드도 정의합니다
def integer_to_shrugs(n)
  SHRUG * n
end

# 이제 클래스 두 개를 정의합니다.
# 첫 번째는 리파인먼트를 사용하고, 두 번째는 사용하지 않습니다.
class TestUsing
  using Shruggable
  def noop
  end

  def shrug
    10.shrugs
  end
end

class TestWithoutUsing
  def noop
  end

  def shrug
    integer_to_shrugs(10)
  end
end

# `using` 키워드 때문에 클래스 초기화가 느려질까요? 아닙니다.
Benchmark.ips do |bm|
  bm.report("class initialization") { TestUsing.new }
  bm.report("class initialization WITH using") { TestWithoutUsing.new }
  bm.compare!
end

# Calculating -------------------------------------
# class initialization   142.929k i/100ms
# class initialization WITH using
#                        145.323k i/100ms
# -------------------------------------------------
# class initialization      5.564M (± 8.3%) i/s -     27.728M
# class initialization WITH using
#                           5.619M (± 7.4%) i/s -     28.047M
# Comparison:
# class initialization WITH using:  5618601.3 i/s
# class initialization:  5564116.5 i/s - 1.01x slower

# 리파인먼트가 일반 메서드 탐색 속도에 영향을 줄까요? 아닙니다.
Benchmark.ips do |bm|
  bm.report("run method") { TestUsing.new.noop }
  bm.report("run method in class WITH using") { TestWithoutUsing.new.noop }
  bm.compare!
end

# Calculating -------------------------------------
#           run method   141.905k i/100ms
# run method in class WITH using
#                        144.435k i/100ms
# -------------------------------------------------
#           run method      5.010M (± 6.4%) i/s -     24.975M
# run method in class WITH using
#                           5.086M (± 5.3%) i/s -     25.421M
# Comparison:
# run method in class WITH using:  5086262.3 i/s
#           run method:  5010273.6 i/s - 1.02x slower


# 리파인먼트의 메서드 호출이 일반 메서드보다 느릴까요? 아닙니다.
Benchmark.ips do |bm|
  bm.report("shrug") { TestUsing.new.shrug }
  bm.report("shrug via refinement") { TestWithoutUsing.new.shrug }
  bm.compare!
end

# Calculating -------------------------------------
#                shrug    96.089k i/100ms
# shrug via refinement    95.559k i/100ms
# -------------------------------------------------
#                shrug      1.825M (± 9.3%) i/s -      9.128M
# shrug via refinement      1.929M (± 6.2%) i/s -      9.651M

# Comparison:
# shrug via refinement:  1928841.5 i/s
#                shrug:  1825069.4 i/s - 1.06x slower


# `using` 키워드를 반복 평가하면 성능에 영향이 있을까요? 아주 약간요.
# 공정하지 않은 테스트지만, 어쨌든 리파인먼트가 느려지는
# 사례를 하나는 만들어 보고 싶었습니다 :)
Benchmark.ips do |bm|
  bm.report("inline shrug") { integer_to_shrugs(10) }
  bm.report("inline shrug via refinement") do
    using Shruggable
    10.shrugs
  end
  bm.compare!
end

# Calculating -------------------------------------
#         inline shrug   100.460k i/100ms
# inline shrug via refinement
#                         72.131k i/100ms
# -------------------------------------------------
#         inline shrug      2.507M (± 5.2%) i/s -     12.557M
# inline shrug via refinement
#                           1.498M (± 4.3%) i/s -      7.502M

# Comparison:
#         inline shrug:  2506663.9 i/s
# inline shrug via refinement:  1497747.6 i/s - 1.67x slower