루비는 느린 축에 속하는 언어예요. 그러나, 그렇다고 해서 느리게 코드를 작성할 필요는 없어요. 루비는 굉장히 표현력이 좋은 언어고 같은 일을 하는, 다른 이름을 가진 메서드들마저 존재합니다. 하지만 비슷한 일을 하지만 미묘하게 다른 내부 구현으로 인해 큰 성능차이를 불러오는 경우가 있는데, 그런 경우를 배우고 좀 더 성능 친화적인 코드를 작성해보도록 해요.
우선 기본적으로 벤치마크 작성법을 알아야 해요. 정확한 측정을 동반하지 않으면 최적화는 잘못된 방향으로 흐르기 쉬워요. 기본 benchmark 젬도 훌륭하지만, 좀 더 사용하기 편한 benchmark-ips 젬이나 benchmark-memory 젬을 이용해요.
속도를 확인할 때 사용해요. 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 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
일반적으로 더 적게 할당하면 더 빨라요. 동일한 역할을 하는 것처럼 보이는 메서드들 중 실제로는 제자리에서 변환을 하는 메서드가 있고, 그렇지 않은 메서드가 있어요. 스코프 내에서 지역 변수를 사용하는 경우 제자리에서 변환을 하는 메서드를 이용하면 좀 더 좋은 결과를 얻을 수 있어요.
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
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
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
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
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 를 통해 객체를 얼려주세요.
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
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
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
궁금한 게 생겨서 질문드립니다.
이 부분에 대해서인데,
1. 메모리 최적화는 memoized로만 얻을 순 없고, 반드시 freeze도 해주어야 하는 것일까요?
2. (1이 그렇다면) 루비는 freeze해준다면 해시를 스택 메모리에 할당하는 식으로 동작할까요?
매번 좋은 글 감사합니다 :)