성능
다음 팁을 이용해 스피드와 메모리 측면에서 프로그램의 성능을 최대로 끌어올릴 수 있습니다.
조기 최적화
도날드 크누스(Donald Knuth)는 말한 바 있습니다.
우리는 97% 정도의 작은 효율성에 대해서는 잊어야 한다. 조기 최적화가 만악의 근원이다. 하지만 치명적인 3%에서의 기회를 놓쳐서는 안 된다.
그렇다고 프로그램을 작성하는 도중 의미론적으로 동일하면서 조금만 바꿔도 빨라진다는 것을 깨달았을 때 그 기회를 놓쳐서는 안 됩니다.
그리고 꼭 병목이 어디인지 알아내기 위해 프로그램을 항상 프로파일링하세요. Mac OSX에서는 XCode에 내장된 Instruments Time Profiler를 사용할 수 있습니다. 리눅스에서는 C/C++ 프로그램을 프로파일링 할 수 있는 gprof 같은 프로그램이라면 작동할 것입니다.
반드시 --release
플래그를 이용하여 최적화를 켠 후에 프로그램을 프로파일링 하는 걸 잊지 마세요.
메모리 할당을 방지할 것
프로그램을 만들 때 할 수 있는 최고의 최적화 기술 중 하나는 쓸데없는 메모리 할당을 방지하는 것입니다. 클래스의 인스턴스를 생성할 때 메모리 할당이 일어납니다. 이때 힙 메모리를 할당하게 됩니다. 구조체의 인스턴스를 생성할 때는 스택 메모리를 사용하기 때문에 성능이 영향을 받지 않습니다. 스택과 힙 메모리가 무엇이 다른지 모르신다면 꼭 이곳을 참조하세요.
힙 메모리를 할당하는 것은 느리고, 그 메모리를 나중에 해제해야 하기 때문에 가비지 컬렉터(GC)에 더 부담을 줍니다.
힙 메모리 할당을 방지하기 위한 방법이 몇 가지 있습니다. 표준 라이브러리는 이에 대해 프로그래머를 돕기 위한 방식으로 설계되어 있습니다.
IO에 출력할 때 임시 문자열을 만들지 말 것
표준 출력에 숫자를 쓰기 위하여 다음과 같이 할 수 있습니다.
puts 123
많은 프로그래밍 언어에서의 구현은, 객체를 문자열로 바꾸는 to_s
같은 메서드를 호출하고 그 문자열을 표준 출력에 출력하는 것입니다. 문제 없이 작동하는 방식이지만 결점이 있습니다. 금방 버릴 임시 문자열을 힙 메모리에 쓰고 생성한다는 것입니다. 이는 힙 메모리 할당을 수반하므로 GC에 좀 더 부담이 됩니다.
크리스탈에서 puts
는 객체에 to_s(io)
를 호출하여 문자열을 출력하려는 IO에 그 객체를 넘겨줄 것입니다.
따라서, 다음은 반드시 피해야 합니다.
puts 123.to_s
이는 임시 문자열을 생성합니다. 항상 객체를 IO에 직접 집어넣으세요.
타입을 작성할 때는 반드시 to_s
뿐 아니라 to_s(io)
또한 오버라이드 하세요. 그리고 그 메서드에서 임시 문자열을 가급적이면 생성하지 않는 것이 좋습니다.
class MyClass
# 좋은 예
def to_s(io)
# 임시 문자열 없이 "1, 2"를 IO에 추가함
x = 1
y = 2
io << x << ", " << y
end
# 나쁜 예
def to_s(io)
x = 1
y = 2
# 문자열 보간을 사용하면 임시 문자열이 생기므로
# 가급적 삼가야 함
io << "#{x}, #{y}"
end
end
임시 문자열을 반환하는 대신 IO에 붙여넣는 이 철학은 JSON이나 YAML API 등 다른 API에도 적용됩니다. 이때 데이터를 IO에 직접 쓰기 위해 to_json(io)
와 to_yaml(io)
를 정의해야 합니다. 여러분의 API 정의에도 이 전략을 이용해야 합니다.
시간을 비교해 봅시다.
# io_benchmark.cr
require "benchmark"
io = IO::Memory.new
Benchmark.ips do |x|
x.report("to_s 없이") do
io << 123
io.clear
end
x.report("to_s 사용") do
io << 123.to_s
io.clear
end
end
출력물은 다음과 같습니다.
$ crystal run --release io_benchmark.cr
to_s 없이 77.11M ( 12.97ns) (± 1.05%) fastest
to_s 사용 18.15M ( 55.09ns) (± 7.99%) 4.25× slower
시간뿐만 아니라 메모리 사용의 측면에서 또한 향상되었다는 점을 기억해 둡시다.
임시 객체를 계속 만들지 말 것
다음 코드를 읽어보세요.
lines_with_language_reference = 0
while line = gets
if ["크리스탈", "루비", "자바"].any? { |string| line.includes?(string) }
lines_with_language_reference += 1
end
end
puts "크리스탈, 루비, 자바가 나오는 줄: #{lines_with_language_reference}"
위의 프로그램은 작동하긴 하지만 엄청난 성능 문제가 있습니다. 반복을 돌 때마다 ["crystal", "ruby", "java"]
배열이 새로 생성됩니다.
잊지 마세요. 배열 리터럴은 인스턴스를 생성하고 값을 넣는 작업을 위한 문법적 편의 사항일 뿐이며 반복이 일어날 때마다 매번 배열이 생성될 것입니다.
해결법은 두 가지가 있습니다.
튜플을 사용합니다. 위의 프로그램에서
{"crystal", "ruby", "java"}
를 사용하면 똑같아 보이지만, 튜플은 힙 메모리를 쓰지 않기 때문에 더 빠르고, 메모리를 더 적게 먹고, 컴파일러가 프로그램을 최적화할 확률이 더 높습니다.lines_with_language_reference = 0 while line = gets if {"크리스탈", "루비", "자바"}.any? { |string| line.includes?(string) } lines_with_language_reference += 1 end end puts "크리스탈, 루비, 자바가 나오는 줄: #{lines_with_language_reference}"
배열을 상수로 바꿉니다.
LANGS = ["크리스탈", "루비", "자바"] lines_with_language_reference = 0 while line = gets if LANGS.any? { |string| line.includes?(string) } lines_with_language_reference += 1 end end puts "크리스탈, 루비, 자바가 나오는 줄: #{lines_with_language_reference}"
튜플을 사용하는 것이 더 권장됩니다.
반복문에서의 명시적 배열 리터럴은 임시 객체가 생겨나는 한 경우이지만, 메서드 호출을 통해서도 임시 객체가 생길 수 있습니다. 예를 들어 Hash#keys
는 호출될 때마다 키를 담은 새로운 배열을 반환합니다. 대신에 Hash#each_key
, Hash#has_key?
등 다른 메서드를 사용할 수 있습니다.
가능하면 구조체를 이용할 것
타입을 클래스가 아니라 구조체로 선언한다면 객체 생성은 스택 메모리를 사용하므로, 힙 메모리를 사용할 때보다 더욱 저렴하고 GC에 부담을 주지 않습니다.
하지만 항상 구조체를 사용할 수는 없습니다. 구조체는 값으로 전달되기 때문에 메서드에 구조체를 넘기고 메서드가 그 구조체를 변경한다고 해도 호출하는 쪽에서는 변화를 볼 수 없기 때문에 버그가 생길 수 있습니다. 최선의 방식은 불변 객체, 특히 객체가 작은 경우에 대해서만 구조체를 사용하는 것입니다.
예시는 다음과 같습니다.
# class_vs_struct.cr
require "benchmark"
class PointClass
getter x
getter y
def initialize(@x : Int32, @y : Int32)
end
end
struct PointStruct
getter x
getter y
def initialize(@x : Int32, @y : Int32)
end
end
Benchmark.ips do |x|
x.report("class") { PointClass.new(1, 2) }
x.report("struct") { PointStruct.new(1, 2) }
end
이는 다음을 출력합니다.
$ crystal run --release class_vs_struct.cr
class 28.17M (± 2.86%) 15.29× slower
struct 430.82M (± 6.58%) fastest
문자열 순회
크리스탈의 문자열은 항상 UTF-8로 인코드한 바이트를 담습니다. UTF-8은 가변 길이 인코딩이기 때문에, ASCII 범위의 문자가 바이트 하나로 표현될지라도 한 문자는 여러 바이트에 의해 표현될 수 있습니다. 때문에 String#[]
을 이용해 문자열을 인덱싱 하는 것은 주어진 위치의 문자를 찾기 위해 바이트가 매번 디코드되어야 하므로 O(1)
작업이 아닙니다. 크리스탈의 문자열은, 모든 문자가 ASCII라는 것을 알 때에 한해 String#[]
을 O(1)
으로 구현하는 최적화 기법을 사용합니다. 하지만 일반적으로는 그러지 못합니다.
따라서 이 방식의 문자열의 순회은 최적이 아니며 실제로는 O(n^2)
의 차수를 갖습니다.
string = ...
while i < string.size
char = string[i]
# ...
end
위의 코드는 두 번째 문제가 있습니다. 문자열의 크기(size
)와 문자열에 있는 바이트의 숫자(bytesize
)는 다르기 때문에 문자열의 크기를 계산하는 것 또한 느리다는 것입니다. 하지만 문자열이 크기를 계산하고 나면 그 결과를 캐시합니다.
대신 사용할 수 있는 것은 순회 메서드(each_char
, each_byte
, each_codepoint
)를 사용하거나, 더욱 저수준인 Char::Reader
구조체를 사용하는 것입니다. 예를 들어 each_char
은 다음과 같이 사용합니다.
string = ...
string.each_char do |char|
# ...
end