좀 더 빠른 루비

구경회·2022년 10월 2일
2
post-thumbnail

개요

루비는 느린 축에 속하는 언어예요. 그러나, 그렇다고 해서 느리게 코드를 작성할 필요는 없어요. 루비는 굉장히 표현력이 좋은 언어고 같은 일을 하는, 다른 이름을 가진 메서드들마저 존재합니다. 하지만 비슷한 일을 하지만 미묘하게 다른 내부 구현으로 인해 큰 성능차이를 불러오는 경우가 있는데, 그런 경우를 배우고 좀 더 성능 친화적인 코드를 작성해보도록 해요.

벤치마크 작성법

우선 기본적으로 벤치마크 작성법을 알아야 해요. 정확한 측정을 동반하지 않으면 최적화는 잘못된 방향으로 흐르기 쉬워요. 기본 benchmark 젬도 훌륭하지만, 좀 더 사용하기 편한 benchmark-ips 젬이나 benchmark-memory 젬을 이용해요.

benchmark-ips

속도를 확인할 때 사용해요. report 메서드의 block으로 넘긴 것을 실행하며 ips를 알려주고, 비교해요.

require "benchmark/ips"

Benchmark.ips do |x|
  x.report("+=") do
    s = ""
    s += "hello"
    s += "world"
    s += "!"
  end

  x.report("<<") do
    s = ""
    s << "hello"
    s << "world"
    s << "!"
  end
  
  x.compare!
end

위와 같이 벤치마크 코드를 작성할 수 있어요. 이 결과는 다음과 같아요.

Warming up --------------------------------------
                  +=   192.641k i/100ms
                  <<   240.714k i/100ms
Calculating -------------------------------------
                  +=      3.024M (± 4.9%) i/s -     15.219M in   5.048079s
                  <<      3.877M (± 6.4%) i/s -     19.498M in   5.052987s

Comparison:
                  <<:  3876920.9 i/s
                  +=:  3023958.2 i/s - 1.28x  (± 0.00) slower

benchmark-memory

메모리 사용량을 확인할 때 이용해요. 사용법은 동일해요.

Benchmark.memory do |x|
  x.report("+=") do
    s = ""
    s += "hello"
    s += "world"
    s += "!"
  end

  x.report("<<") do
    s = ""
    s << "hello"
    s << "world"
    s << "!"
  end
  
  x.compare!
end

다음과 같은 결과를 얻을 수 있어요.

Calculating -------------------------------------
                  +=   280.000  memsize (     0.000  retained)
                         7.000  objects (     0.000  retained)
                         6.000  strings (     0.000  retained)
                  <<   160.000  memsize (     0.000  retained)
                         4.000  objects (     0.000  retained)
                         4.000  strings (     0.000  retained)

Comparison:
                  <<:        160 allocated
                  +=:        280 allocated - 1.75x more

Allocate less

일반적으로 더 적게 할당하면 더 빨라요. 동일한 역할을 하는 것처럼 보이는 메서드들 중 실제로는 제자리에서 변환을 하는 메서드가 있고, 그렇지 않은 메서드가 있어요. 스코프 내에서 지역 변수를 사용하는 경우 제자리에서 변환을 하는 메서드를 이용하면 좀 더 좋은 결과를 얻을 수 있어요.

String, += vs <<

TL;DR <<를 += 보다 선호하세요.

require "benchmark/ips"
require "benchmark-memory"

Benchmark.ips do |x|
  x.report("+=") do
    s = ""
    s += "hello"
    s += "world"
    s += "!"
  end

  x.report("<<") do
    s = ""
    s << "hello"
    s << "world"
    s << "!"
  end

  x.report("<< frozen") do
    s = ""
    s << "hello".freeze
    s << "world".freeze
    s << "!".freeze
  end
  x.compare!
end

Benchmark.memory do |x|
  x.report("+=") do
    s = ""
    s += "hello"
    s += "world"
    s += "!"
  end

  x.report("<<") do
    s = ""
    s << "hello"
    s << "world"
    s << "!"
  end

  x.report("<< frozen") do
    s = ""
    s << "hello".freeze
    s << "world".freeze
    s << "!".freeze
  end
  x.compare!
end
  • benchmark-ips
Warming up --------------------------------------
                  +=   186.549k i/100ms
                  <<   248.847k i/100ms
           << frozen   565.486k i/100ms
Calculating -------------------------------------
                  +=      3.058M (± 2.5%) i/s -     15.297M in   5.004940s
                  <<      3.922M (± 5.1%) i/s -     19.659M in   5.024795s
           << frozen      5.640M (± 2.1%) i/s -     28.274M in   5.015631s

Comparison:
           << frozen:  5639675.1 i/s
                  <<:  3922328.2 i/s - 1.44x  (± 0.00) slower
                  +=:  3058317.6 i/s - 1.84x  (± 0.00) slower
  • memory
Calculating -------------------------------------
                  +=   280.000  memsize (     0.000  retained)
                         7.000  objects (     0.000  retained)
                         6.000  strings (     0.000  retained)
                  <<   160.000  memsize (     0.000  retained)
                         4.000  objects (     0.000  retained)
                         4.000  strings (     0.000  retained)
           << frozen    40.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         1.000  strings (     0.000  retained)

Comparison:
           << frozen:         40 allocated
                  <<:        160 allocated - 4.00x more
                  +=:        280 allocated - 7.00x more

<< 는 원본을 파괴하고 제자리에 문자열을 덧붙이지만, +=는 새로운 문자열을 만든 후 그걸 덧붙여요. object_id를 확인해보면 알 수 있어요.

>> x = ""
=> ""
>> x.object_id
=> 476240
>> x += "hello"
=> "hello"
>> x.object_id
=> 476260
>> x << "world"
=> "helloworld"
>> x.object_id
=> 476260

가능하다면 상수를 선호해주세요

TL; DR 반복적인 값은 상수로 빼서 적게 할당해주세요.

우리가 흔히 쓰는 패턴인 다음과 같은 경우를 생각해봐요.

Rails.Cache.fetch("key", expires_in: 4.hours) do
  # some value
end

이런 경우 4.hours가 매번 fetch를 부를 때마다 호출되어서 비효율적으로 동작해요. 다음과 같이 단순한 코드로 검증해볼게요.

require "benchmark/ips"
require "benchmark-memory"

TTL = 15.minutes

Benchmark.ips do |x|

  x.report("~constant") do
    Rails.Cache.fetch("~constant", expires_in: 15.minutes) { 42 }
  end

  x.report("constant") do
    Rails.Cache.fetch("constant", expires_in: TTL) { 42 }
  end

  x.compare!
end
Benchmark.memory do |x|
  x.report("~constant") do
    Rails.Cache.fetch("~constant", expires_in: 15.minutes) { 42 }
  end

  x.report("constant") do
    Rails.Cache.fetch("constant", expires_in: TTL) { 42 }
  end

  x.compare!
end
  • benchmark-ips
Warming up --------------------------------------
           ~constant    34.161k i/100ms
            constant    94.459k i/100ms
Calculating -------------------------------------
           ~constant    790.786k (± 7.2%) i/s -      3.963M in   5.039191s
            constant      1.367M (± 4.5%) i/s -      6.896M in   5.053703s

Comparison:
            constant:  1367119.3 i/s
           ~constant:   790786.1 i/s - 1.73x  (± 0.00) slower
  • benchmark-memory
Calculating -------------------------------------
           ~constant   416.000  memsize (     0.000  retained)
                         4.000  objects (     0.000  retained)
                         1.000  strings (     0.000  retained)
            constant   208.000  memsize (     0.000  retained)
                         2.000  objects (     0.000  retained)
                         1.000  strings (     0.000  retained)

Comparison:
            constant:        208 allocated
           ~constant:        416 allocated - 2.00x more

가능하다면 얼려주세요

TL; DR 가능하다면 #freeze 를 통해 객체를 얼려주세요.

Keyword Arguments

TL; DR def func(arg, options={}) 패턴의 경우 OPTS={}.freeze def func(arg, options=OPS)로 최적화해주세요.

opts = {}의 경우 인자 없는 함수호출마다 빈 해시를 만들고 넘겨줘요. 이 대신 freeze된 해시를 상수로 정의하고 사용하면 이 과정을 최적화해서 메모리 사용량과 성능을 잡을 수 있어요.

def some(opts = {})
  opts[:name]
end

OPTS = {}.freeze

def none(opts = OPTS)
  opts[:name]
end

require "benchmark-memory"
require "benchmark/ips"

Benchmark.ips do |x|
  x.report("default") do
    some
  end
  x.report("frozen hash") do
    none
  end
  x.compare!
end

Benchmark.memory do |x|
  x.report("default") do
    100.times.each { |_| some }
  end
  x.report("frozen hash") do
    100.times.each { |_| none }
  end
  x.compare!
end
  • benchmark-ips
Warming up --------------------------------------
             default     1.186M i/100ms
         frozen hash     1.545M i/100ms
Calculating -------------------------------------
             default     12.047M (± 2.4%) i/s -     60.502M in   5.025321s
         frozen hash     15.581M (± 1.1%) i/s -     78.770M in   5.056138s

Comparison:
         frozen hash: 15580837.1 i/s
             default: 12046633.4 i/s - 1.29x  (± 0.00) slower
  • benchmark-memory
Calculating -------------------------------------
             default     4.136k memsize (     0.000  retained)
                       101.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)
         frozen hash   136.000  memsize (     0.000  retained)
                         1.000  objects (     0.000  retained)
                         0.000  strings (     0.000  retained)

Comparison:
         frozen hash:        136 allocated
             default:       4136 allocated - 30.41x more

참고문헌

profile
즐기는 거야

4개의 댓글

comment-user-thumbnail
2023년 2월 25일

궁금한 게 생겨서 질문드립니다.

opts = {}의 경우 인자 없는 함수호출마다 빈 해시를 만들고 넘겨줘요. 이 대신 freeze된 해시를 상수로 정의하고 사용하면 이 과정을 최적화해서 메모리 사용량과 성능을 잡을 수 있어요.

이 부분에 대해서인데,
1. 메모리 최적화는 memoized로만 얻을 순 없고, 반드시 freeze도 해주어야 하는 것일까요?
2. (1이 그렇다면) 루비는 freeze해준다면 해시를 스택 메모리에 할당하는 식으로 동작할까요?

매번 좋은 글 감사합니다 :)

1개의 답글