성능 팁
성능 팁 (Performance Tips)
이어지는 절들에서는 Julia 코드를 가능한 한 빠르게 실행하는 데 도움이 되는 몇 가지 기법을 간단히 살펴볼게요.
본문
일반적인 조언 (General advice)
성능이 중요한 코드는 함수 안에 넣어야 해요 (Performance critical code should be inside a function)
성능이 중요한 코드는 모두 함수 안에 있어야 해요. Julia 컴파일러의 동작 방식 때문에, 함수 안의 코드는 최상위 코드보다 훨씬 빠르게 실행되는 경향이 있어요.
함수의 사용은 성능 때문만은 아니에요. 함수는 더 재사용 가능하고 테스트하기 쉬우며, 어떤 단계가 수행되고 입력·출력이 무엇인지 명확히 해줘요. 스크립트로만 끝내지 말고 함수로 작성하세요는 Julia 스타일가이드의 권장사항이기도 해요.
함수는 전역 변수에 직접 연산하기보다 인자를 받아야 해요. 이건 다음 항목에서 다룰게요.
타입이 없는 전역 변수를 피하세요 (Avoid untyped global variables)
타입이 없는 전역 변수의 값은 언제든 바뀔 수 있고, 그 타입이 바뀔 수도 있어요. 이 때문에 컴파일러가 전역 변수를 쓰는 코드를 최적화하기 어려워져요. 이는 타입 값 변수, 즉 전역 레벨의 타입 별칭에도 적용돼요. 가능할 때마다 변수는 지역 변수로 두거나 함수에 인자로 전달해야 해요.
전역 이름은 흔히 상수인데, 그렇게 선언하면 성능이 크게 개선돼요.
const DEFAULT_VAL = 0
비상수 전역이 항상 같은 타입인 것으로 알려져 있다면 타입을 주석으로 달아야 해요. const 전역은 초기화 값에서 타입이 추론되므로 주석이 필요 없어요.
타입이 없는 전역의 사용은 사용 지점에서 타입을 주석으로 달아 최적화할 수 있어요.
global x = rand(1000)
function loop_over_global()
s = 0.0
for i in x::Vector{Float64}
s += i
end
return s
end
함수에 인자를 전달하는 게 더 좋은 스타일이에요. 더 재사용 가능한 코드를 만들고 입력과 출력이 무엇인지 명확히 해주거든요.
참고: REPL의 모든 코드는 전역 스코프에서 평가되므로, 최상위 레벨에서 정의·할당된 변수는 전역 변수가 돼요. 모듈 내부의 최상위 레벨 스코프에서 정의된 변수도 전역이에요.
다음 REPL 세션에서:
julia> x = 1.0
1.0
는 다음와 동등해요:
julia> global x = 1.0
1.0
따라서 앞서 논의한 모든 성능 문제가 적용돼요.
@time로 성능을 측정하고 메모리 할당에 주의하세요 (Measure performance with @time and pay attention to memory allocation)
성능 측정에 유용한 도구는 @time 매크로예요. 여기서는 위의 전역 변수 예제를 반복하되, 이번엔 타입 주석을 제거할게요.
julia> x = rand(1000);
julia> function sum_global()
s = 0.0
for i in x
s += i
end
return s
end;
julia> @time sum_global()
0.011539 seconds (9.08 k allocations: 373.386 KiB, 98.69% compilation time)
523.0007221951678
julia> @time sum_global()
0.000091 seconds (3.49 k allocations: 70.156 KiB)
523.0007221951678
첫 호출(@time sum_global())에서 함수가 컴파일돼요. (이 세션에서 아직 @time을 쓰지 않았다면, 타이밍에 필요한 함수들도 컴파일돼요.) 이 실행 결과는 진지하게 받아들이면 안 돼요. 두 번째 실행을 보면, 시간 보고 외에도 상당한 메모리가 할당됐다는 것도 알 수 있어요. 여기서 우리는 64비트 float 벡터의 모든 요소를 합산하는 것뿐인데, (힙) 메모리를 할당할 필요가 없어야 해요.
분명히 하자면, @time이 보고하는 건 구체적으로 힙 할당이에요. 힙 할당은 보통 변경 가능한(mutable) 객체나, 크기가 변하는 컨테이너(Array·Dict·문자열, 또는 런타임에만 타입이 알려지는 "타입 불안정한" 객체)를 만들거나 키울 때 필요해요. 그런 메모리 블록을 할당(또는 해제)하는 것은 libc에 대한 비싼 함수 호출(C의 malloc 등)을 요구할 수 있고, 가비지 컬렉션을 위해 추적되어야 해요. 반대로 숫자(bignum 제외), 튜플, 불변 struct 같은 불변 값은 스택이나 CPU 레지스터 메모리처럼 훨씬 저렴하게 저장할 수 있어서, "할당"의 성능 비용을 보통 걱정하지 않아요.
예상치 못한 메모리 할당은 거의 항상 코드에 뭔가 문제가 있다는 신호예요. 대개 타입 안정성 문제거나 작은 임시 배열을 많이 만드는 문제죠. 따라서 할당 자체 외에도, 여러분 함수에 대해 생성된 코드가 최적과는 거리가 멀 가능성이 아주 높아요. 그런 신호를 진지하게 받아들이고 아래의 조언을 따르세요.
Julia의 메모리 관리와 가비지 컬렉션에 대한 자세한 내용은 Memory Management and Garbage Collection을 참고하세요.
이 특별한 경우, 메모리 할당은 타입 불안정한 전역 변수 x를 사용했기 때문이에요. 그래서 x를 함수에 인자로 전달하면 메모리를 할당하지 않게 되고(아래 보고되는 남은 할당은 @time 매크로를 전역 스코프에서 실행하기 때문이에요), 첫 호출 이후에는 훨씬 빨라져요.
julia> x = rand(1000);
julia> function sum_arg(x)
s = 0.0
for i in x
s += i
end
return s
end;
julia> @time sum_arg(x)
0.007551 seconds (3.98 k allocations: 200.548 KiB, 99.77% compilation time)
523.0007221951678
julia> @time sum_arg(x)
0.000006 seconds (1 allocation: 16 bytes)
523.0007221951678
보이는 1개의 할당은 @time 매크로 자체를 전역 스코프에서 실행했기 때문이에요. 타이밍을 함수 안에서 실행하면 실제로 할당이 없음을 확인할 수 있어요.
julia> time_sum(x) = @time sum_arg(x);
julia> time_sum(x)
0.000002 seconds
523.0007221951678
어떤 상황에서는 함수가 작업의 일부로 메모리를 할당할 필요가 있을 수 있고, 그러면 위의 단순한 그림이 복잡해져요. 그런 경우엔 아래의 도구 중 하나를 사용해 문제를 진단하거나, 할당을 알고리즘 측면과 분리하는 함수 버전을 작성해보세요 (출력 미리 할당 참고).
참고: 더 엄격한 벤치마킹을 위해서는 BenchmarkTools.jl 패키지를 고려해보세요. 이 패키지는 노이즈를 줄이기 위해 함수를 여러 번 평가하는 등의 기능을 제공해요.
함수를 여러 정의로 쪼개세요 (Break functions into multiple definitions)
함수를 많은 작은 정의로 작성하면 컴파일러가 가장 적합한 코드를 직접 호출하거나 인라인할 수 있어요.
이것은 여러 정의로 작성해야 하는 "복합 함수(compound function)"의 예시예요.
using LinearAlgebra
function mynorm(A)
if isa(A, Vector)
return sqrt(real(dot(A,A)))
elseif isa(A, Matrix)
return maximum(svdvals(A))
else
error("mynorm: invalid argument")
end
end
이것을 더 간결하고 효율적으로 이렇게 쓸 수 있어요.
mynorm(x::Vector) = sqrt(real(dot(x, x)))
mynorm(A::Matrix) = maximum(svdvals(A))
하지만 컴파일러는 mynorm 예제처럼 작성된 코드의 죽은 분기를 최적화해 제거하는 데 꽤 능숙하다는 점도 알아두세요.
도구 (Tools)
Julia와 그 패키지 생태계에는 코드의 문제를 진단하고 성능을 개선하는 데 도움이 되는 도구가 포함돼 있어요.
- 프로파일링은 실행 중인 코드의 성능을 측정하고 병목이 되는 줄을 식별하게 해 줘요. 복잡한 프로젝트에서는 ProfileView 패키지가 프로파일링 결과를 시각화하는 데 도움을 줘요.
- JET 패키지는 코드의 흔한 성능 문제를 찾는 데 도움을 줘요.
- 예상치 못하게 큰 메모리 할당—
@time,@allocated, 프로파일러(가비지 컬렉션 루틴 호출을 통한)가 보고한—은 코드에 문제가 있을 가능성을 시사해요. 할당에 대한 다른 이유가 보이지 않으면 타입 문제를 의심해보세요.--track-allocation=user옵션으로 Julia를 시작하고 결과*.mem파일을 검사해 그 할당이 어디서 발생하는지에 대한 정보를 볼 수도 있어요. 메모리 할당 분석을 참고하세요. @code_warntype는 타입 불확실성을 초래하는 표현식을 찾는 데 도움이 되는 코드 표현을 생성해요. 아래의@code_warntype을 참고하세요.
타입 추론 (Type inference)
선택적 타입 선언이 있는 많은 언어에서, 선언을 추가하는 것이 코드를 더 빠르게 만드는 주요 방법이에요. Julia에서는 그렇지 않아요. Julia에서 컴파일러는 일반적으로 모든 함수 인자, 지역 변수, 표현식의 타입을 알아요. 하지만 선언이 도움이 되는 특정 인스턴스가 몇 가지 있어요.
추상 타입 파라미터를 가진 컨테이너를 피하세요 (Avoid containers with abstract type parameters)
배열을 포함한 파라미터화된 타입을 다룰 때는 가능하면 추상 타입으로 파라미터화하는 것을 피하는 게 좋아요.
다음을 고려해볼게요:
julia> a = Real[]
Real[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
1
2.0
π = 3.1415926535897...
a는 추상 타입 Real의 배열이므로, 어떤 Real 값이든 담을 수 있어야 해요. Real 객체는 크기와 구조가 임의적일 수 있으므로, a는 각각 개별 할당된 Real 객체를 가리키는 포인터 배열로 표현되어야 해요. 하지만 대신 같은 타입의 숫자, 예를 들어 Float64만 저장하도록 허용하면, 이것들을 더 효율적으로 저장할 수 있어요.
julia> a = Float64[]
Float64[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Float64}:
1.0
2.0
3.141592653589793
이제 숫자를 a에 할당하면 Float64로 변환되고, a는 효율적으로 조작할 수 있는 64비트 부동소수점 값의 연속 블록으로 저장돼요.
추상 값 타입을 가진 컨테이너를 피할 수 없다면, 런타임 타입 검사를 피하기 위해 Any로 파라미터화하는 게 때로는 더 나아요. 예를 들어 IdDict{Any, Any}는 IdDict{Type, Vector}보다 성능이 좋아요.
파라미터화된 타입 아래의 논의도 참고하세요.
추상 타입을 가진 필드를 피하세요 (Avoid fields with abstract type)
타입은 필드의 타입을 지정하지 않고 선언할 수 있어요.
julia> struct MyAmbiguousType
a
end
이렇게 하면 a가 어떤 타입이든 될 수 있어요. 이건 종종 유용할 수 있지만 단점이 있어요. MyAmbiguousType 타입의 객체에 대해서는 컴파일러가 고성능 코드를 생성할 수 없어요. 이유는 컴파일러가 코드를 어떻게 만들지 결정하기 위해 객체의 값이 아니라 타입을 사용하기 때문이에요. 불행히도 MyAmbiguousType 타입의 객체에 대해서는 추론할 수 있는 게 아주 적어요.
julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")
julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)
julia> typeof(b)
MyAmbiguousType
julia> typeof(c)
MyAmbiguousType
b와 c의 값은 같은 타입인데도, 메모리에서의 밑바탕 데이터 표현은 아주 달라요. 필드 a에 숫자 값만 저장한다 해도, UInt8의 메모리 표현과 Float64의 표현이 다르다는 사실은 CPU가 두 종류의 서로 다른 명령을 사용해 처리해야 한다는 뜻이에요. 필요한 정보가 타입에 없으므로 그런 결정을 런타임에 해야 해요. 이는 성능을 느리게 해요.
a의 타입을 선언하면 더 나아질 수 있어요. 여기서는 a가 여러 타입 중 하나일 수 있는 경우에 집중할 건데, 그때 자연스러운 해법은 파라미터를 사용하는 거예요. 예를 들어:
julia> mutable struct MyType{T<:AbstractFloat}
a::T
end
이건 이보다 더 나은 선택이에요:
julia> mutable struct MyStillAmbiguousType
a::AbstractFloat
end
첫 번째 버전이 래퍼 객체의 타입에서 a의 타입을 지정하기 때문이에요. 예를 들어:
julia> m = MyType(3.2)
MyType{Float64}(3.2)
julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)
julia> typeof(m)
MyType{Float64}
julia> typeof(t)
MyStillAmbiguousType
필드 a의 타입은 m의 타입에서 쉽게 결정할 수 있지만, t의 타입에서는 결정할 수 없어요. 실제로 t에서는 필드 a의 타입을 바꾸는 게 가능해요:
julia> typeof(t.a)
Float64
julia> t.a = 4.5f0
4.5f0
julia> typeof(t.a)
Float32
반대로 m이 한번 구성되면 m.a의 타입은 바뀔 수 없어요.
julia> m.a = 4.5f0
4.5f0
julia> typeof(m.a)
Float64
m.a의 타입이 m의 타입에서 알려진다는 사실—그 타입이 함수 중간에 바뀔 수 없다는 사실과 결합되어—컴파일러가 m 같은 객체에 대해 고도로 최적화된 코드를 생성할 수 있게 하지만, t 같은 객체에 대해서는 그럴 수 없어요.
물론 이 모든 것은 m을 구체 타입으로 구성할 때만 성립해요. 명시적으로 추상 타입으로 구성해 이걸 깨뜨릴 수 있어요.
julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)
julia> typeof(m.a)
Float64
julia> m.a = 4.5f0
4.5f0
julia> typeof(m.a)
Float32
실용적인 목적상, 그런 객체들은 MyStillAmbiguousType의 객체들과 똑같이 동작해요.
간단한 함수에 대해 생성되는 코드의 양을 비교하는 것은 꽤 유익해요.
func(m::MyType) = m.a+1
사용해보면:
code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})
길이 때문에 결과는 여기 보여주지 않지만, 직접 시도해보길 권해요. 첫 번째 경우 타입이 완전히 지정되어 있으므로 컴파일러가 런타임에 타입을 해석하기 위한 코드를 생성할 필요가 없어요. 결과적으로 더 짧고 더 빠른 코드가 나와요.
또한 완전히 파라미터화되지 않은 타입은 추상 타입처럼 동작한다는 점을 명심해야 해요. 예를 들어 완전히 지정된 Array{T,n}은 구체지만, 파라미터 없이 주어진 Array 자체는 구체가 아니에요.
julia> !isconcretetype(Array), !isabstracttype(Array), isstructtype(Array), !isconcretetype(Array{Int}), isconcretetype(Array{Int,1})
(true, true, true, true, true)
이 경우 MyType에 필드 a::Array를 선언하는 것보다, 필드를 a::Array{T,N}으로 선언하거나, {T,N}이나 A가 MyType의 파라미터가 되도록 a::A로 선언하는 게 더 낫겠어요.
앞선 조언은 struct의 필드가 함수, 더 일반적으로는 호출 가능한 객체로 의도될 때 특히 유용해요. 다음과 같이 struct를 정의하고 싶은 유혹이 크죠:
struct MyCallableWrapper
f::Function
end
하지만 Function은 추상 타입이므로, wrapper.f에 대한 모든 호출은 필드 f에 접근하는 타입 불안정성 때문에 동적 디스패치를 요구해요. 대신 이렇게 작성해야 해요:
struct MyCallableWrapper{F}
f::F
end
거의 동일한 동작을 가지면서 훨씬 빨라져요 (타입 불안정성이 제거되기 때문이에요). F<:Function을 강제하지 않는다는 점에 주의하세요. 이는 Function의 하위 타입이 아닌 호출 가능한 객체도 필드 f에 허용된다는 뜻이에요.
추상 컨테이너를 가진 필드를 피하세요 (Avoid fields with abstract containers)
동일한 모범 사례가 컨테이너 타입에도 적용돼요.
julia> struct MySimpleContainer{A<:AbstractVector}
a::A
end
julia> struct MyAmbiguousContainer{T}
a::AbstractVector{T}
end
julia> struct MyAlsoAmbiguousContainer
a::Array
end
예를 들어:
julia> c = MySimpleContainer(1:3);
julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}
julia> c = MySimpleContainer([1:3;]);
julia> typeof(c)
MySimpleContainer{Vector{Int64}}
julia> b = MyAmbiguousContainer(1:3);
julia> typeof(b)
MyAmbiguousContainer{Int64}
julia> b = MyAmbiguousContainer([1:3;]);
julia> typeof(b)
MyAmbiguousContainer{Int64}
julia> d = MyAlsoAmbiguousContainer(1:3);
julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Int64})
julia> d = MyAlsoAmbiguousContainer(1:1.0:3);
julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Float64})
MySimpleContainer의 경우, 객체는 그 타입과 파라미터로 완전히 지정되므로 컴파일러가 최적화된 함수를 생성할 수 있어요. 대부분의 경우 이것으로 충분할 거예요.
컴파일러가 이제 제 일을 완벽하게 하더라도, 코드가 a의 요소 타입에 따라 다른 일을 하길 바라는 경우가 있을 수 있어요. 보통 이를 달성하는 가장 좋은 방법은 특정 연산(여기서는 foo)을 별도의 함수로 감싸는 거예요.
julia> function sumfoo(c::MySimpleContainer)
s = 0
for x in c.a
s += foo(x)
end
s
end
sumfoo (generic function with 1 method)
julia> foo(x::Integer) = x
foo (generic function with 1 method)
julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)
이렇게 하면 컴파일러가 모든 경우에 최적화된 코드를 생성할 수 있게 하면서도, 상황을 단순하게 유지해요.
하지만 MySimpleContainer의 필드 a의 AbstractVector의 요소 타입이나 타입에 따라 외부 함수의 다른 버전을 선언해야 하는 경우가 있을 수 있어요. 이렇게 할 수 있어요.
julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
return c.a[1]+1
end
myfunc (generic function with 1 method)
julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
return c.a[1]+2
end
myfunc (generic function with 2 methods)
julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
return c.a[1]+3
end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3))
2
julia> myfunc(MySimpleContainer(1.0:3))
3.0
julia> myfunc(MySimpleContainer([1:3;]))
4
타입이 없는 위치에서 가져온 값에 주석을 다세요 (Annotate values taken from untyped locations)
어떤 타입의 값이든 담을 수 있는 데이터 구조(Array{Any} 타입의 배열)로 작업하는 것이 종종 편리해요. 하지만 이런 구조 중 하나를 사용하면서 요소의 타입을 우연히 안다면, 그 지식을 컴파일러와 공유하는 게 도움이 돼요.
function foo(a::Array{Any,1})
x = a[1]::Int32
b = x+1
...
end
여기서는 a의 첫 요소가 Int32일 거라는 걸 우연히 알았어요. 이렇게 주석을 다는 건 추가 이점이 있는데, 값이 예상 타입이 아니면 런타임 오류를 일으켜 특정 버그를 더 일찍 잡을 가능성이 커지죠.
a[1]의 타입이 정확히 알려지지 않은 경우, x를 x = convert(Int32, a[1])::Int32로 선언할 수 있어요. convert 함수를 사용하면 a[1]이 Int32로 변환 가능한 어떤 객체든(예: UInt8) 될 수 있게 되어, 타입 요구를 느슨하게 함으로써 코드의 일반성을 높여요. convert 자체는 타입 안정성을 달성하기 위해 이 맥락에서 타입 주석이 필요하다는 점에 주목하세요. 컴파일러는 함수의 모든 인자 타입을 알지 않는 한, convert라 할지라도 함수의 반환 타입을 추론할 수 없기 때문이에요.
타입 주석은 타입이 추상적이거나 런타임에 구성되면 성능을 향상시키지 않아요 (오히려 방해할 수 있어요). 컴파일러가 주석으로 후속 코드를 특수화할 수 없고, 타입 검사 자체가 시간을 잡아먹기 때문이에요. 예를 들어 다음 코드에서:
function nr(a, prec)
ctype = prec == 32 ? Float32 : Float64
b = Complex{ctype}(a)
c = (b + 1.0f0)::Complex{ctype}
abs(c)
end
c의 주석은 성능을 해쳐요. 런타임에 구성되는 타입과 관련된 고성능 코드를 작성하려면 아래에서 논의할 함수 배리어 기법을 사용하고, 커널 함수의 인자 타입에 구성된 타입이 나타나도록 해서 커널 연산이 컴파일러에 의해 적절히 특수화되게 하세요. 예를 들어 위 스니펫에서 b가 구성되는 즉시 다른 함수 k, 즉 커널에 넘길 수 있어요. 함수 k가 b를 타입 파라미터 T를 가진 Complex{T} 타입의 인자로 선언한다면, k 안의 할당 문에 나타나는 다음과 같은 형태의 타입 주석은
c = (b + 1.0f0)::Complex{T}
성능을 방해하지 않아요 (하지만 도움도 되지 않아요). 컴파일러가 k가 컴파일될 때 c의 타입을 결정할 수 있기 때문이에요.
Julia가 특수화를 피하는 시기를 알아두세요 (Be aware of when Julia avoids specializing)
휴리스틱으로서, Julia는 세 가지 특정 경우에 인자 타입 파라미터에 대해 자동 특수화를 피해요: Type, Function, Vararg예요. Julia는 인자가 메서드 안에서 사용될 때는 항상 특수화하지만, 인자가 그냥 다른 함수로 통과될 뿐이면 그렇지 않아요. 이는 보통 런타임 성능에는 영향이 없고 컴파일러 성능을 개선해요. 여러분의 경우 런타임 성능에 영향이 있는 걸 발견하면, 메서드 선언에 타입 파라미터를 추가해 특수화를 촉발할 수 있어요. 몇 가지 예시가 있어요.
다음은 특수화되지 않아요:
function f_type(t) # or t::Type
x = ones(t, 10)
return sum(map(sin, x))
end
하지만 이건 특수화돼요:
function g_type(t::Type{T}) where T
x = ones(T, 10)
return sum(map(sin, x))
end
다음은 특수화되지 않아요:
f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))
하지만 이건 특수화돼요:
h_func(h::H, num) where {H} = ntuple(h, div(num, 2))
다음은 특수화되지 않아요:
f_vararg(x::Int...) = tuple(x...)
하지만 이건 특수화돼요:
g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)
다른 타입이 제약되지 않더라도 타입 파라미터 하나만 도입하면 특수화를 강제할 수 있어요. 예를 들어 이건 특수화되며, 인자들이 모두 같은 타입이 아닐 때 유용해요:
h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)
@code_typed와 그 친구들은, Julia가 보통 해당 메서드 호출을 특수화하지 않더라도, 항상 특수화된 코드를 보여준다는 점에 주의하세요. 인자 타입이 바뀔 때 특수화가 생성되는지, 즉 Base.specializations(@which f(...))에 해당 인자에 대한 특수화가 포함되는지 확인하고 싶다면 메서드 내부를 확인해야 해요.
"타입 안정적인(type-stable)" 함수를 작성하세요 (Write "type-stable" functions)
가능할 때, 함수가 항상 같은 타입의 값을 반환하도록 하는 것이 도움이 돼요. 다음 정의를 고려해볼게요.
pos(x) = x < 0 ? 0 : x
무해해 보이지만, 문제는 0이 정수(Int 타입)이고 x는 어떤 타입이든 될 수 있다는 거예요. 따라서 x의 값에 따라 이 함수는 두 타입 중 하나의 값을 반환할 수 있어요. 이 동작은 허용되며, 어떤 경우에는 바람직할 수도 있어요. 하지만 다음과 같이 쉽게 고칠 수 있어요.
pos(x) = x < 0 ? zero(x) : x
oneunit 함수와, x의 타입으로 변환된 y를 반환하는 더 일반적인 oftype(x, y) 함수도 있어요.
변수의 타입을 바꾸지 마세요 (Avoid changing the type of a variable)
함수 안에서 반복 사용되는 변수에 대해서도 유사한 "타입 안정성" 문제가 존재해요.
function foo()
x = 1
for i = 1:10
x /= rand()
end
return x
end
지역 변수 x는 정수로 시작하지만, 한 루프 반복 후에는 부동소수점 숫자(/ 연산자의 결과)가 돼요. 이 때문에 컴파일러가 루프의 본문을 최적화하기 더 어려워져요. 가능한 고쳐쓰기 방법이 몇 가지 있어요.
x를x = 1.0으로 초기화x의 타입을x::Float64 = 1로 명시적으로 선언x = oneunit(Float64)로 명시적 변환 사용- 첫 루프 반복으로
x = 1 / rand()로 초기화한 다음,for i = 2:10으로 루프
커널 함수 분리하기 (aka, 함수 배리어) (Separate kernel functions (aka, function barriers))
많은 함수가 어떤 설정 작업을 수행한 다음 핵심 계산을 수행하기 위해 여러 반복을 실행하는 패턴을 따라요. 가능하면 이런 핵심 계산을 별도의 함수로 두는 것이 좋아요. 예를 들어 다음 인위적인 함수는 무작위로 선택된 타입의 배열을 반환해요.
julia> function strange_twos(n)
a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
for i = 1:n
a[i] = 2
end
return a
end;
julia> strange_twos(3)
3-element Vector{Int64}:
2
2
2
이걸 이렇게 작성해야 해요.
julia> function fill_twos!(a)
for i = eachindex(a)
a[i] = 2
end
end;
julia> function strange_twos(n)
a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
fill_twos!(a)
return a
end;
julia> strange_twos(3)
3-element Vector{Int64}:
2
2
2
Julia의 컴파일러는 함수 경계에서 인자 타입에 대한 코드를 특수화하므로, 원래 구현에서는 루프 동안 a의 타입을 알 수 없어요 (무작위로 선택되니까요). 따라서 두 번째 버전이 일반적으로 더 빠른데, 내부 루프가 a의 다른 타입에 대해 fill_twos!의 일부로 재컴파일될 수 있기 때문이에요.
두 번째 형태는 종종 더 좋은 스타일이기도 하고 코드 재사용도 늘릴 수 있어요.
이 패턴은 Julia Base의 여러 곳에서 사용돼요. 예를 들어 abstractarray.jl의 vcat과 hcat, 또는 우리가 자체 fill_twos!을 쓰는 대신 쓸 수 있었던 fill! 함수를 보세요.
strange_twos 같은 함수는 타입이 불확실한 데이터를 다룰 때 발생하는데, 예를 들어 정수·float·문자열 또는 뭔가 다른 것을 포함할 수 있는 입력 파일에서 로드된 데이터가 그렇죠.
@code_warntype
매크로 @code_warntype (또는 함수 버전 code_warntype)은 때때로 타입 관련 문제를 진단하는 데 도움이 돼요. 여기 예시가 있어요.
julia> @noinline pos(x) = x < 0 ? 0 : x;
julia> function f(x)
y = pos(x)
return sin(y*x + 1)
end;
julia> @code_warntype f(3.2)
MethodInstance for f(::Float64)
from f(x) @ Main REPL[9]:1
Arguments
#self#::Core.Const(f)
x::Float64
Locals
y::Union{Float64, Int64}
Body::Float64
1 ─ (y = Main.pos(x))
│ %2 = (y * x)::Float64
│ %3 = (%2 + 1)::Float64
│ %4 = Main.sin(%3)::Float64
└── return %4
@code_warntype의 출력을 해석하는 데는, 그 사촌들인 @code_lowered, @code_typed, @code_llvm, @code_native의 출력과 마찬가지로 약간의 연습이 필요해요. 여러분의 코드는 컴파일된 기계 코드를 생성하는 과정에서 많이 소화된 형태로 제시돼요. 대부분의 표현식은 ::T로 표시된 타입으로 주석이 달려 있어요 (여기서 T는 예를 들어 Float64일 수 있어요). @code_warntype의 가장 중요한 특징은 비-구체 타입이 빨간색으로 표시된다는 거예요. 이 문서는 색이 없는 Markdown으로 작성되었으므로, 이 문서에서는 빨간 텍스트를 대문자로 표시해요.
위쪽에서 함수의 추론된 반환 타입이 Body::Float64로 표시돼요. 다음 줄들은 Julia의 SSA IR 형태로 된 f의 본문을 나타내요. 번호가 매겨진 박스는 라벨이며, 코드에서 goto를 통한 점프의 대상을 나타내요. 본문을 보면 맨 처음 일어나는 일은 pos가 호출되고 반환 값이 Union{Float64, Int64} 타입으로 추론되었다는 걸 볼 수 있어요. 비-구체 타입이라 대문자로 표시됐죠. 이는 입력 타입을 근거로 pos의 정확한 반환 타입을 알 수 없다는 뜻이에요. 하지만 y*x의 결과는 y가 Float64든 Int64든 Float64예요. 최종 결과적으로 f(x::Float64)는 중간 계산 중 일부가 타입 불안정하더라도 출력에서 타입 불안정하지 않아요.
이 정보를 어떻게 사용할지는 여러분에게 달려 있어요. 당연히 pos를 타입 안정적으로 고치는 게 가장 좋겠죠. 그렇게 하면 f의 모든 변수가 구체 타입이 되고 성능도 최적이 돼요. 하지만 이런 일시적인(ephemeral) 타입 불안정성이 그렇게 중요하지 않을 수 있는 상황도 있어요. 예를 들어 pos가 단독으로는 사용되지 않는 경우, f의 출력이 (입력이 Float64일 때) 타입 안정적이라는 사실이 후속 코드를 타입 불안정의 전파 효과로부터 보호해줘요. 이는 타입 불안정을 고치기 어렵거나 불가능한 경우에 특히 관련이 있어요. 그런 경우엔 위의 팁들(타입 주석 추가, 함수 쪼개기 등)이 타입 불안정의 "피해"를 옮기지 않는 최선의 도구예요. 또한 Julia Base에도 타입 불안정한 함수가 있다는 점을 알아두세요. 예를 들어 함수 findfirst는 키가 발견된 배열 안의 인덱스를 반환하거나, 발견되지 않으면 nothing을 반환하는데, 이는 명백한 타입 불안정이에요. 중요할 가능성이 있는 타입 불안정을 더 쉽게 찾도록, missing이나 nothing을 포함하는 Union은 빨간색 대신 노란색으로 색상 강조돼요.
다음 예시들은 비-구체 타입을 담은 것으로 표시된 표현식을 해석하는 데 도움이 될 수 있어요.
-
Body::Union{T1,T2})로 시작하는 함수 본문- 해석: 불안정한 반환 타입을 가진 함수
- 제안: 주석을 달아야 하더라도 반환 값을 타입 안정적으로 만들 것
-
invoke Main.g(%%x::Int64)::Union{Float64, Int64}- 해석: 타입 불안정한 함수
g에 대한 호출 - 제안: 함수를 고치거나, 필요하면 반환 값에 주석을 달 것
- 해석: 타입 불안정한 함수
-
invoke Base.getindex(%%x::Array{Any,1}, 1::Int64)::Any- 해석: 타입이 제대로 정의되지 않은 배열의 요소 접근
- 제안: 더 잘 정의된 타입의 배열을 쓰거나, 필요하면 개별 요소 접근의 타입에 주석을 달 것
-
Base.getfield(%%x, :(:data))::Array{Float64,N} where N- 해석: 비-구체 타입인 필드 가져오기. 이 경우
x의 타입, 예컨대ArrayContainer, 에 필드data::Array{T}가 있었지만,Array는 구체 타입이 되려면 차원N도 필요해요. - 제안:
Array{T,3}이나,N이 이제ArrayContainer의 파라미터가 되는Array{T,N}같은 구체 타입을 사용할 것
- 해석: 비-구체 타입인 필드 가져오기. 이 경우
캡처된 변수(captured variable)의 성능 (Performance of captured variable)
내부 함수를 정의하는 다음 예제를 고려해볼게요.
function abmult(r::Int)
if r < 0
r = -r
end
f = x -> x * r
return f
end
함수 abmult는 인자를 r의 절댓값으로 곱하는 함수 f를 반환해요. f에 할당된 내부 함수는 "클로저(closure)"라고 불러요. 내부 함수는 do-블록과 생성자 표현식에도 언어에 의해 사용돼요.
이런 스타일의 코드는 언어에 성능 문제를 제기해요. 파서가 이를 더 낮은 수준의 명령으로 번역할 때, 내부 함수를 별도의 코드 블록으로 추출하면서 위 코드를 상당히 재구성해요. 내부 함수와 그 감싸는 스코프가 공유하는 r 같은 "캡처된" 변수들도, 외부 스코프(또는 다른 내부 함수)가 r을 수정한 후에도 내부 스코프의 r이 외부 스코프의 r과 동일해야 한다고 언어가 명시하기 때문에, 내부·외부 함수 모두가 접근할 수 있는 힙 할당 "박스(box)"로 추출돼요.
앞 문단의 논의는 "파서"를 언급했는데, 이는 abmult를 담은 모듈이 처음 로드될 때 일어나는 컴파일 단계를 말하며, 나중에 처음 호출될 때 일어나는 단계와는 대조돼요. 파서는 Int가 고정 타입이라는 것도, r = -r 문장이 Int를 또 다른 Int로 변환한다는 것도 "알지" 못해요. 타입 추론의 마법은 더 나중의 컴파일 단계에서 일어나요.
따라서 파서는 r이 고정 타입(Int)을 가진다는 것도, 내부 함수가 생성된 후 r이 값을 바꾸지 않는다는 것도 알지 못해요 (그래서 박스가 필요 없다는 것도요). 따라서 파서는 Any 같은 추상 타입의 객체를 담는 박스에 대한 코드를 생성하며, 이는 r의 각 사용에 대해 런타임 타입 디스패치를 요구해요. 이는 위 함수에 @code_warntype을 적용해 확인할 수 있어요. 박싱(boxing)과 런타임 타입 디스패치 모두 성능 손실을 일으킬 수 있어요.
캡처된 변수가 코드의 성능이 중요한 구간에서 사용된다면, 다음 팁들이 그 사용을 고성능으로 보장하는 데 도움을 줘요. 먼저, 캡처된 변수가 타입을 바꾸지 않는 것으로 알려져 있다면, (오른쪽이 아니라) 변수에 타입 주석으로 명시적으로 선언할 수 있어요.
function abmult2(r0::Int)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
타입 주석은 파서가 박스 안의 객체에 구체 타입을 연관시킬 수 있으므로, 캡처로 잃은 성능을 부분적으로 회복해요. 더 나아가, 캡처된 변수가 전혀 박스될 필요가 없다면(클로저가 만들어진 후 재할당되지 않기 때문에), let 블록으로 나타낼 수 있어요.
function abmult3(r::Int)
if r < 0
r = -r
end
f = let r = r
x -> x * r
end
return f
end
let 블록은 스코프가 내부 함수뿐인 새 변수 r을 만들어요. 두 번째 기법은 캡처된 변수가 있어도 완전한 언어 성능을 회복해요. 이는 컴파일러의 빠르게 진화하는 측면이며, 미래 릴리스에서는 이런 수준의 프로그래머 주석을 요구하지 않을 가능성이 높다는 점에 유의하세요. 그동안 FastClosures 같은 몇몇 사용자 기여 패키지가 abmult3처럼 let 문 삽입을 자동화해요.
재귀 클로저에는 @__FUNCTION__을 사용하세요 (Use @__FUNCTION__ for recursive closures)
특히 재귀 클로저에는 @__FUNCTION__ 매크로가 타입 불안정성과 박싱을 모두 피할 수 있어요.
먼저 최적화되지 않은 버전을 볼게요.
function make_fib_unoptimized()
fib(n) = n <= 1 ? 1 : fib(n - 1) + fib(n - 2) # fib is boxed
return fib
end
fib 함수는 박스되며, 반환 타입이 Any로 추론된다는 뜻이에요.
@code_warntype make_fib_unoptimized()
이제 이 타입 불안정성을 없애기 위해, 대신 @__FUNCTION__을 사용해 구체 함수 객체를 가리킬 수 있어요.
function make_fib_optimized()
fib(n) = n <= 1 ? 1 : (@__FUNCTION__)(n - 1) + (@__FUNCTION__)(n - 2)
return fib
end
이렇게 하면 구체 반환 타입을 얻을 수 있어요.
@code_warntype make_fib_optimized()
값-파라미터 타입 (Types with values-as-parameters)
각 축을 따라 크기 3인 N차원 배열을 만들고 싶다고 가정해볼게요. 그런 배열은 이렇게 만들 수 있어요.
julia> A = fill(5.0, (3, 3))
3×3 Matrix{Float64}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
이 접근 방식은 아주 잘 동작해요. 컴파일러는 채울 값의 타입(5.0::Float64)과 차원((3, 3)::NTuple{2,Int})을 알기 때문에 A가 Array{Float64,2}임을 알아낼 수 있어요. 이는 컴파일러가 같은 함수 안에서 A의 미래 사용법에 대해 아주 효율적인 코드를 생성할 수 있다는 뜻이에요.
하지만 이제 임의의 차원에서 3×3×... 배열을 만드는 함수를 작성하고 싶다고 가정해볼게요. 이런 함수를 쓰고 싶은 유혹이 들 수 있어요.
julia> function array3(fillval, N)
fill(fillval, ntuple(d->3, N))
end
array3 (generic function with 1 method)
julia> array3(5.0, 2)
3×3 Matrix{Float64}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
이건 동작하지만 (@code_warntype array3(5.0, 2)로 직접 확인할 수 있듯이) 문제는 출력 타입을 추론할 수 없다는 거예요. 인자 N은 Int 타입의 값이고, 타입 추론은 그 값을 미리 예측하지 못해요 (그리고 할 수도 없어요). 이는 이 함수의 출력을 사용하는 코드가 보수적이어야 하고, A에 접근할 때마다 타입을 검사해야 한다는 뜻이며, 그런 코드는 아주 느릴 거예요.
이제 이런 문제를 푸는 아주 좋은 방법 하나는 함수 배리어 기법을 사용하는 거예요. 하지만 어떤 경우에는 타입 불안정성을 완전히 없애고 싶을 수도 있어요. 그런 경우의 한 접근 방식은 차원을 파라미터로, 예를 들어 Val{T}()로 전달하는 거예요 ("값 타입" 참고).
julia> function array3(fillval, ::Val{N}) where N
fill(fillval, ntuple(d->3, Val(N)))
end
array3 (generic function with 1 method)
julia> array3(5.0, Val(2))
3×3 Matrix{Float64}:
5.0 5.0 5.0
5.0 5.0 5.0
5.0 5.0 5.0
Julia에는 Val{::Int} 인스턴스를 두 번째 파라미터로 받는 특수화된 버전의 ntuple이 있어요. N을 타입 파라미터로 전달함으로써 그 "값"을 컴파일러에게 알려줘요. 결과적으로 이 array3 버전은 컴파일러가 반환 타입을 예측할 수 있게 해 줘요.
하지만 그런 기법을 사용하는 것은 놀라울 정도로 미묘할 수 있어요. 예를 들어 이런 함수에서 array3를 호출하면 아무 도움이 되지 않아요.
function call_array3(fillval, n)
A = array3(fillval, Val(n))
end
여기서 같은 문제를 다시 만들었어요. 컴파일러가 n이 무엇인지 추측할 수 없으므로 Val(n)의 타입을 알지 못해요. Val을 사용하려고 하지만 잘못 사용하면 많은 상황에서 성능을 더 나쁘게 쉽게 만들 수 있어요. (커널 함수를 더 효율적으로 만들기 위해 Val을 함수 배리어 기법과 효과적으로 결합하는 상황에서만 위 같은 코드를 써야 해요.)
Val의 올바른 사용 예시는 이래요.
function filter3(A::AbstractArray{T,N}) where {T,N}
kernel = array3(1, Val(N))
filter(A, kernel)
end
이 예제에서 N은 파라미터로 전달되므로 그 "값"이 컴파일러에게 알려져요. 본질적으로 Val(T)는 T가 하드코딩/리터럴(Val(3))이거나 이미 타입 도메인에 지정된 경우에만 동작해요.
다중 디스패치 남용의 위험 (aka, 값-파라미터 타입에 대한 더 많은 내용) (The dangers of abusing multiple dispatch (aka, more on types with values-as-parameters))
다중 디스패치를 감상하게 되면, 과하게 몰아서 모든 것에 그걸 쓰려고 하는 이해할 만한 경향이 생겨요. 예를 들어 정보를 저장하는 데 사용하길 상상할 수 있어요.
struct Car{Make, Model}
year::Int
...more fields...
end
그런 다음 Car{:Honda,:Accord}(year, args...) 같은 객체에 디스패치하죠.
다음 중 하나라도 해당하면 이게 가치가 있을 수 있어요.
- 각
Car에 CPU 집약적 처리가 요구되고,Make와Model을 컴파일 타임에 알면 훨씬 더 효율적이 되며, 사용될 서로 다른Make나Model의 총 개수가 너무 많지 않을 때. - 같은 타입의
Car의 동질적인 리스트를 처리해서, 모두Array{Car{:Honda,:Accord},N}에 저장할 수 있을 때.
후자가 해당하면, 그런 동질적인 배열을 처리하는 함수는 생산적으로 특수화될 수 있어요. Julia가 각 요소의 타입을 미리 알고(컨테이너의 모든 객체가 같은 구체 타입이므로), 함수가 컴파일될 때 올바른 메서드 호출을 "조회"할 수 있어서(런타임 검사가 필요 없어지고) 전체 리스트를 처리하는 효율적인 코드를 만들기 때문이에요.
이것들이 해당하지 않으면 아마 이득이 없을 거예요. 더 나쁘게, 결과적인 "타입의 조합 폭발(combinatorial explosion of types)"은 역효과일 거예요. items[i+1]이 item[i]와 다른 타입이라면, Julia는 런타임에 타입을 조회하고, 메서드 테이블에서 적절한 메서드를 찾고, (타입 교집합으로) 어떤 것이 매칭되는지 결정하고, 아직 JIT 컴파일됐는지 확인하고(아니면 그렇게 하고), 그런 다음 호출해야 해요. 본질적으로 여러분은 전체 타입 시스템과 JIT 컴파일 기계에게, 여러분 코드에서 switch 문이나 사전 조회에 해당하는 것을 실행하라고 요구하는 거예요.
(1) 타입 디스패치, (2) 사전 조회, (3) "switch" 문을 비교한 런타임 벤치마크 몇 개가 discourse에 있어요.
런타임 영향보다 어쩌면 더 나쁜 것은 컴파일 타임 영향이에요. Julia는 각각의 다른 Car{Make, Model}에 대해 특수화된 함수를 컴파일할 거예요. 그런 타입이 수백·수천 개라면, 그런 객체를 파라미터로 받는 모든 함수가(직접 작성할 수 있는 사용자 정의 get_year 함수부터 Julia Base의 일반 push! 함수까지) 수백·수천 개의 변형을 컴파일하게 돼요. 각각은 컴파일된 코드 캐시의 크기, 메서드의 내부 목록 길이 등을 늘려요. 값-파라미터에 대한 과도한 열정은 엄청난 자원을 쉽게 낭비할 수 있어요.
메모리 관리와 배열 (Memory management and arrays)
출력 미리 할당하기 (Pre-allocate outputs)
함수가 Array나 다른 복잡한 타입을 반환한다면 메모리를 할당해야 할 수 있어요. 불행히도 할당과 그 반대인 가비지 컬렉션은 자주 상당한 병목이 돼요.
때로는 출력을 미리 할당해 함수 호출마다 메모리를 할당할 필요를 우회할 수 있어요. 사소한 예로, 다음을 비교해보세요.
julia> function xinc(x)
return [x + i for i in 1:3000]
end;
julia> function loopinc()
y = 0
for i = 1:10^5
ret = xinc(i)
y += ret[2]
end
return y
end;
this와 함께요.
julia> function xinc!(ret::AbstractVector{T}, x::T) where T
for i in 1:3000
ret[i] = x+i
end
nothing
end;
julia> function loopinc_prealloc()
ret = Vector{Int}(undef, 3000)
y = 0
for i = 1:10^5
xinc!(ret, i)
y += ret[2]
end
return y
end;
타이밍 결과:
julia> @time loopinc()
0.297454 seconds (200.00 k allocations: 2.239 GiB, 39.80% gc time)
5000250000
julia> @time loopinc_prealloc()
0.009410 seconds (2 allocations: 23.477 KiB)
5000250000
미리 할당은 다른 이점도 있어요. 예를 들어 호출자가 알고리즘의 "출력" 타입을 제어할 수 있게 해 줘요. 위 예제에서 원한다면 Array 대신 SubArray를 넘겼을 수도 있어요.
극단으로 가면 미리 할당은 코드를 더 못생기게 만들 수 있어서, 성능 측정과 약간의 판단이 필요할 수 있어요. 하지만 "벡터화된"(요소별) 함수의 경우, x .= f.(y)라는 편리한 문법을 fuse된 루프와 임시 배열 없이 제자리 연산에 쓸 수 있어요 (함수 벡터화를 위한 dot 문법 참고).
슬라이스에는 views 사용을 고려하세요 (Consider using views for slices)
Julia에서 array[1:5, :] 같은 배열 "슬라이스" 표현식은 (할당의 왼쪽에서는 array[1:5, :] = ...이 array의 그 부분에 제자리 할당하지만) 그 데이터의 복사본을 만들어요. 슬라이스에 많은 연산을 한다면, 원래 배열을 인덱싱하는 것보다 더 작은 연속 복사본으로 작업하는 게 더 효율적이므로 성능에 좋을 수 있어요. 반면 슬라이스에 몇 가지 간단한 연산만 한다면, 할당과 복사 연산의 비용이 상당할 수 있어요.
대안은 배열의 "view"를 만드는 거예요. 이는 실제로 원래 배열의 데이터를 제자리에서 참조하는 배열 객체(SubArray)로, 복사본을 만들지 않아요. (view에 쓰면 원래 배열의 데이터도 수정돼요.) 개별 슬라이스에 대해서는 view를 호출하거나, 더 간단하게는 전체 표현식이나 코드 블록 앞에 @views를 두면 돼요. 예를 들어:
julia> fcopy(x) = sum(x[2:end-1]);
julia> @views fview(x) = sum(x[2:end-1]);
julia> x = rand(10^6);
julia> @time fcopy(x);
0.003051 seconds (3 allocations: 7.629 MB)
julia> @time fview(x);
0.001020 seconds (1 allocation: 16 bytes)
fview 버전 함수의 3배 속도 향상과 메모리 할당 감소를 모두 볼 수 있어요.
작은 고정 크기 벡터/행렬 연산에는 StaticArrays.jl을 고려하세요 (Consider StaticArrays.jl for small fixed-size vector/matrix operations)
애플리케이션이 많은 작은(< 100 요소) 고정 크기(즉 실행 전에 크기를 알고 있는) 배열을 다룬다면, StaticArrays.jl 패키지 사용을 고려해볼 만해요. 이 패키지는 그런 배열을 불필요한 힙 할당을 피하고 컴파일러가 배열의 크기에 대해 코드를 특수화하게 하는 방식으로 표현할 수 있게 해 줘요. 예를 들어 벡터 연산을 완전히 언롤(unrolling)하고(루프 제거) 요소를 CPU 레지스터에 저장해서요.
예를 들어 2차원 도형으로 계산한다면, 2-성분 벡터로 많은 계산을 하게 될 수 있어요. StaticArrays.jl의 SVector 타입을 사용하면, 컴파일러가 코드를 @inbounds hypot(3v[1]-w[1], 3v[2]-w[2])에 해당하는 최소 계산으로 언롤할 수 있게 하면서, 벡터 v와 w에 대해 norm(3v - w) 같은 편리한 벡터 표기법과 연산을 쓸 수 있어요.
dot을 더 많이: 벡터화된 연산을 fuse하세요 (More dots: Fuse vectorized operations)
Julia에는 어떤 스칼라 함수든 "벡터화된" 함수 호출로, 어떤 연산자든 "벡터화된" 연산자로 변환하는 특별한 dot 문법이 있어요. 중첩된 "dot 호출"이 fusing되는 특별한 속성을 가지죠. 문법 레벨에서 단일 루프로 결합되며 임시 배열을 할당하지 않아요. .=과 유사한 할당 연산자를 사용하면 결과를 미리 할당된 배열에 제자리로 저장할 수도 있어요 (위 참고).
선형대수 맥락에서, 이는 vector + vector와 vector * scalar 같은 연산이 정의되어 있음에도, vector .+ vector와 vector .* scalar를 대신 사용하는 게 유리할 수 있다는 뜻이에요. 결과 루프가 주변 계산과 fuse될 수 있기 때문이에요. 예를 들어 두 함수를 고려해볼게요.
julia> f(x) = 3x.^2 + 4x + 7x.^3;
julia> fdot(x) = @. 3x^2 + 4x + 7x^3; # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3
f와 fdot 모두 같은 것을 계산해요. 하지만 fdot (@. 매크로의 도움으로 정의된)는 배열에 적용될 때 훨씬 빠르다는 것이 확인돼요.
julia> x = rand(10^6);
julia> @time f(x);
0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time)
julia> @time fdot(x);
0.002790 seconds (6 allocations: 7.630 MiB)
julia> @time f.(x);
0.002626 seconds (8 allocations: 7.630 MiB)
즉 fdot(x)는 f(x)보다 열 배 빠르고 메모리를 1/6만 할당해요. f(x)의 각 *와 + 연산이 새 임시 배열을 할당하고 별도의 루프에서 실행되기 때문이에요. 이 예제에서 f.(x)는 fdot(x)만큼 빠르지만, 많은 맥락에서 벡터화된 연산마다 별도 함수를 정의하는 것보다 표현식에 dot을 조금 뿌리는 게 더 편리해요.
dot을 더 적게: 특정 중간 broadcast를 unfuse하세요 (Fewer dots: Unfuse certain intermediate broadcasts)
위에서 언급한 dot 루프 fuse는 고성능 연산을 표현하는 간결하고 관용적인 코드를 가능하게 해요. 하지만 fuse된 연산이 broadcast의 매 반복마다 계산된다는 점을 기억하는 게 중요해요. 이는 어떤 상황, 특히 복합적이거나 다차원 broadcast가 있을 때 dot 호출이 있는 표현식이 의도보다 더 많이 함수를 계산할 수 있다는 뜻이에요. 예를 들어, 행마다 유클리드 노름이 1인 랜덤 행렬을 만들고 싶다고 가정해볼게요. 이렇게 쓸 수 있어요.
julia> x = rand(1000, 1000);
julia> d = sum(abs2, x; dims=2);
julia> @time x ./= sqrt.(d);
0.002049 seconds (4 allocations: 96 bytes)
이건 동작해요. 하지만 이 표현식은 실제로 행 x[i, :]의 모든 요소에 대해 sqrt(d[i])를 재계산해서, 필요한 것보다 훨씬 많은 제곱근을 계산하게 돼요. broadcast가 정확히 어떤 인덱스 위를 순회할지 보려면 fuse된 표현식의 인자들에 대해 Broadcast.combine_axes를 호출하면 돼요. 이는 순회의 축에 해당하는 범위들의 튜플을 반환하고, 이 범위들의 길이의 곱은 fuse된 연산의 총 호출 수가 돼요.
따라서 broadcast 표현식의 어떤 성분이 한 축을 따라 상수일 때—앞선 예제에서 두 번째 차원을 따른 sqrt처럼—그 성분들을 강제로 "unfuse"하면 성능 개선의 여지가 있어요. 즉 broadcast된 연산의 결과를 미리 할당하고 그 상수 축을 따라 캐시된 값을 재사용하는 거죠. 그런 몇 가지 잠재적 접근 방식은 임시 변수를 사용하거나, dot 표현식의 성분을 identity로 감싸거나, 동등한 본질적으로 벡터화된(하지만 fuse되지 않은) 함수를 사용하는 거예요.
julia> @time let s = sqrt.(d); x ./= s end;
0.000809 seconds (5 allocations: 8.031 KiB)
julia> @time x ./= identity(sqrt.(d));
0.000608 seconds (5 allocations: 8.031 KiB)
julia> @time x ./= map(sqrt, d);
0.000611 seconds (4 allocations: 8.016 KiB)
이 옵션들 중 어떤 것이든 할당 하나를 대가로 대략 3배의 속도 향상을 줘요. 큰 broadcastable에서는 이 속도 향상이 점근적으로 아주 클 수 있어요.
배열을 메모리 순서로, 열을 따라 접근하세요 (Access arrays in memory order, along columns)
Julia의 다차원 배열은 열-우선(column-major) 순서로 저장돼요. 이는 배열이 한 번에 한 열씩 쌓인다는 뜻이에요. 이는 vec 함수나 아래처럼 [:] 문법으로 확인할 수 있어요 (배열이 [1 3 2 4] 순서이며 [1 2 3 4]가 아니라는 점을 주목하세요).
julia> x = [1 2; 3 4]
2×2 Matrix{Int64}:
1 2
3 4
julia> x[:]
4-element Vector{Int64}:
1
3
2
4
배열을 정렬하는 이 관례는 Fortran, Matlab, R 같은 많은 언어에서 흔해요 (몇 가지 이름만 들자면). 열-우선 정렬의 대안은 행-우선(row-major) 정렬로, C와 Python(numpy) 등이 채택한 관례예요. 배열의 순서를 기억하는 것은 배열을 순회할 때 상당한 성능 효과를 가질 수 있어요. 명심할 경험칙은, 열-우선 배열에서 첫 번째 인덱스가 가장 빠르게 변한다는 거예요. 본질적으로 이것은 가장 안쪽 루프 인덱스가 슬라이스 표현식에서 먼저 나타나면 순회가 더 빠르다는 뜻이에요. 배열을 :로 인덱싱하는 것은 특정 차원의 모든 요소를 반복적으로 접근하는 암시적 루프라는 점을 기억하세요. 예를 들어 행보다 열을 추출하는 게 더 빠를 수 있어요.
다음 인위적인 예제를 고려해볼게요. Vector를 받아 그 행이나 열을 입력 벡터의 복사본으로 채운 정사각 Matrix를 반환하는 함수를 작성하고 싶다고 상상해보세요. 행으로 채우든 열로 채우든 중요하지 않다고 가정해볼게요 (나머지 코드가 그에 맞게 쉽게 적응할 수 있다면요). 이 것을 (권장되는 내장 repeat 호출 외에) 적어도 네 가지 방식으로 할 수 있어요.
function copy_cols(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for i = inds
out[:, i] = x
end
return out
end
function copy_rows(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for i = inds
out[i, :] = x
end
return out
end
function copy_col_row(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for col = inds, row = inds
out[row, col] = x[row]
end
return out
end
function copy_row_col(x::Vector{T}) where T
inds = axes(x, 1)
out = similar(Array{T}, inds, inds)
for row = inds, col = inds
out[row, col] = x[col]
end
return out
end
이제 같은 랜덤한 10000×1 입력 벡터로 각 함수의 시간을 잴게요.
julia> x = randn(10000);
julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x))
julia> map(fmt, [copy_cols, copy_rows, copy_col_row, copy_row_col]);
copy_cols: 0.331706323
copy_rows: 1.799009911
copy_col_row: 0.415630047
copy_row_col: 1.721531501
copy_cols가 copy_rows보다 훨씬 빠르다는 점을 주목하세요. copy_cols가 Matrix의 열 기반 메모리 배치를 존중하고 한 번에 한 열씩 채우기 때문에 예상할 수 있는 결과예요. 또한 copy_col_row가 copy_row_col보다 훨씬 빠른데, 슬라이스 표현식에 첫 번째로 나타나는 요소를 가장 안쪽 루프와 결합해야 한다는 경험칙을 따르기 때문이에요.
데이터 복사가 항상 나쁜 건 아니에요 (Copying data is not always bad)
배열은 메모리에 연속적으로 저장되어, CPU 벡터화에 유리하고 캐싱 덕분에 더 적은 메모리 접근을 허용해요. 이것은 열-우선 순서로 배열에 접근하도록 권장되는 것과 같은 이유예요 (위 참고). 불규칙한 접근 패턴과 비연속적인 view는 비순차 메모리 접근 때문에 배열에 대한 계산을 극적으로 느리게 할 수 있어요.
반복적으로 접근하기 전에 불규칙하게 접근되는 데이터를 연속 배열로 복사하면 아래 예제처럼 큰 속도 향상을 얻을 수 있어요. 여기서 행렬이 곱해지기 전에 무작위로 섞인 인덱스로 접근되고 있어요. 평범한 배열로 복사하면 복사와 할당 비용을 더하더라도 곱셈이 빨라져요.
julia> using Random
julia> A = randn(3000, 3000);
julia> x = randn(2000);
julia> inds = shuffle(1:3000)[1:2000];
julia> function iterated_neural_network(A, x, depth)
for _ in 1:depth
x .= max.(0, A * x)
end
argmax(x)
end
julia> @time iterated_neural_network(view(A, inds, inds), x, 10)
0.324903 seconds (12 allocations: 157.562 KiB)
1569
julia> @time iterated_neural_network(A[inds, inds], x, 10)
0.054576 seconds (13 allocations: 30.671 MiB, 13.33% gc time)
1569
메모리가 충분하다면, view를 배열로 복사하는 비용은 연속 배열에서 반복적인 행렬 곱셈을 함으로써 얻는 속도 향상에 압도돼요.
멀티스레딩과 선형대수 (Multithreading and linear algebra)
이 섹션은 각 스레드에서 선형대수 연산을 수행하는 멀티스레드 Julia 코드에 적용돼요. 실제로 이 선형대수 연산들은 BLAS/LAPACK 호출을 수반하며, 그것들 자체가 멀티스레드예요. 이 경우 두 가지 다른 종류의 멀티스레딩 때문에 코어가 과잉 할당(oversubscribed)되지 않도록 해야 해요.
Julia는 선형대수용으로 OpenBLAS의 자체 사본을 컴파일·사용하며, 그 스레드 수는 환경변수 OPENBLAS_NUM_THREADS로 제어돼요. Julia를 시작할 때 커맨드라인 옵션으로 설정하거나, Julia 세션 중에 BLAS.set_num_threads(N)으로 수정할 수 있어요 (BLAS 하위모듈은 using LinearAlgebra로 내보내져요). 현재 값은 BLAS.get_num_threads()로 접근할 수 있어요.
사용자가 아무것도 지정하지 않으면 Julia는 OpenBLAS 스레드 수에 합리적인 값을 선택하려고 해요 (예: 플랫폼, Julia 버전 등에 기반). 하지만 일반적으로 값을 직접 확인하고 설정하는 것이 권장돼요. OpenBLAS 동작은 다음과 같아요.
OPENBLAS_NUM_THREADS=1이면, OpenBLAS는 호출하는 Julia 스레드를 사용해요. 즉 계산을 실행하는 Julia 스레드 안에 "살아요".OPENBLAS_NUM_THREADS=N>1이면, OpenBLAS는 자체 스레드 풀(총N개)을 만들고 관리해요. 모든 Julia 스레드가 공유하는 OpenBLAS 스레드 풀은 하나뿐이에요.
JULIA_NUM_THREADS=X로 멀티스레드 모드에서 Julia를 시작할 때는 일반적으로 OPENBLAS_NUM_THREADS=1로 설정하는 것이 권장돼요. 위에서 설명한 동작을 고려할 때, BLAS 스레드 수를 N>1로 늘리는 것은 특히 N<X일 때 성능을 쉽게 더 나쁘게 만들 수 있어요. 하지만 이건 경험칙일 뿐이고, 각 스레드 수를 설정하는 가장 좋은 방법은 여러분의 특정 애플리케이션에서 실험해보는 거예요.
대안적 선형대수 백엔드 (Alternative linear algebra backends)
OpenBLAS의 대안으로, 선형대수 성능에 도움이 되는 몇 가지 다른 백엔드가 있어요. 대표적으로 MKL.jl과 AppleAccelerate.jl이 있어요.
이들은 외부 패키지이므로 여기서 자세히 다루지 않을게요. 각각의 문서를 참고하세요 (특히 멀티스레딩 관점에서 OpenBLAS와 다른 동작을 보이므로).
실행 지연, 패키지 로딩 및 패키지 프리컴파일 시간 (Execution latency, package loading and package precompiling time)
첫 플롯까지의 시간 줄이기 등 (Reducing time to first plot etc.)
Julia 메서드가 처음 호출되면 (그 메서드가 호출하는 메서드나 정적으로 결정할 수 있는 메서드들도) 컴파일돼요. @time 매크로 계열이 이를 보여줘요.
julia> foo() = rand(2,2) * rand(2,2)
foo (generic function with 1 method)
julia> @time @eval foo();
0.252395 seconds (1.12 M allocations: 56.178 MiB, 2.93% gc time, 98.12% compilation time)
julia> @time @eval foo();
0.000156 seconds (63 allocations: 2.453 KiB)
@time @eval이 컴파일 시간 측정에 더 낫다는 점에 주목하세요. @eval이 없으면 타이밍 시작 전에 일부 컴파일이 이미 끝났을 수 있기 때문이에요.
패키지를 개발할 때는 *프리컴파일(precompilation)*으로 사용자 경험을 개선할 수 있어요. 사용자가 패키지를 사용할 때 그들이 쓰는 코드가 이미 컴파일되어 있도록 말이에요. 패키지 코드를 효과적으로 프리컴파일하려면, PrecompileTools.jl을 사용해 프리컴파일 시간 동안 일반적인 패키지 사용을 대표하는 "프리컴파일 워크로드"를 실행하는 것이 권장돼요. 그러면 네이티브 컴파일된 코드를 패키지 pkgimage 캐시에 캐시해, 그런 사용에 대한 "첫 실행까지의 시간"(종종 TTFX라고 함)을 크게 줄여줘요.
PrecompileTools.jl 워크로드는 패키지 개발 중일 수 있듯이 프리컴파일에 추가 시간을 쓰고 싶지 않다면, Preferences를 통해 비활성화하고 때로는 설정할 수 있다는 점에 주목하세요.
패키지 로딩 시간 줄이기 (Reducing package loading time)
패키지를 로드하는 데 걸리는 시간을 낮게 유지하는 것은 보통 도움이 돼요. 패키지 개발자를 위한 일반적인 좋은 관행은 다음과 같아요.
- 의존성을 정말 필요한 것들로 줄여요. 필수 의존성을 비대하게 만들지 않고 다른 패키지와의 상호운용성을 지원하려면 패키지 확장 사용을 고려해보세요.
- 달리 방법이 없지 않은 한
__init__()함수 사용을 피하세요. 특히 많은 컴파일을 촉발하거나 실행에 오래 걸리는 함수는요. - 가능하면 의존성들 사이와 여러분의 패키지 코드에서 무효화(invalidations)를 고치세요.
@time_imports 도구는 REPL에서 위 요인들을 검토하는 데 유용할 수 있어요.
julia> @time @time_imports using Plots
0.5 ms Printf
16.4 ms Dates
0.7 ms Statistics
┌ 23.8 ms SuiteSparse_jll.__init__() 86.11% compilation time (100% recompilation)
90.1 ms SuiteSparse_jll 91.57% compilation time (82% recompilation)
0.9 ms Serialization
┌ 39.8 ms SparseArrays.CHOLMOD.__init__() 99.47% compilation time (100% recompilation)
166.9 ms SparseArrays 23.74% compilation time (100% recompilation)
0.4 ms Statistics → SparseArraysExt
0.5 ms TOML
8.0 ms Preferences
0.3 ms PrecompileTools
0.2 ms Reexport
... many deps omitted for example ...
1.4 ms Tar
┌ 73.8 ms p7zip_jll.__init__() 99.93% compilation time (100% recompilation)
79.4 ms p7zip_jll 92.91% compilation time (100% recompilation)
┌ 27.7 ms GR.GRPreferences.__init__() 99.77% compilation time (100% recompilation)
43.0 ms GR 64.26% compilation time (100% recompilation)
┌ 2.1 ms Plots.__init__() 91.80% compilation time (100% recompilation)
300.9 ms Plots 0.65% compilation time (100% recompilation)
1.795602 seconds (3.33 M allocations: 190.153 MiB, 7.91% gc time, 39.45% compilation time: 97% of which was recompilation)
이 예제에서 여러 패키지가 로드되고, 일부는 __init__() 함수를 가지며, 그중 일부는 컴파일을 일으키고 그중 일부는 재컴파일(recompilation)이라는 점을 주목하세요. 재컴파일은 더 앞선 패키지들이 메서드를 무효화하고, 그런 다음 이어지는 패키지들이 __init__() 함수를 실행할 때 일부가 코드를 실행하기 전에 재컴파일을 만나기 때문에 발생해요.
또한 Statistics 확장 SparseArraysExt가 SparseArrays가 의존성 트리에 있기 때문에 활성화되었다는 점도 주목하세요. 즉 0.4 ms Statistics → SparseArraysExt를 보세요.
이 보고서는 의존성 로드 시간의 비용이 그 기능이 가져오는 가치가 있는지 검토할 좋은 기회예요. 또한 Pkg 유틸리티 why를 사용해 간접 의존성이 왜 존재하는지 보고할 수 있어요.
(CustomPackage) pkg> why FFMPEG_jll
Plots → FFMPEG → FFMPEG_jll
Plots → GR → GR_jll → FFMPEG_jll
또는 패키지가 가져오는 간접 의존성을 보려면, pkg> rm으로 패키지를 제거하고 manifest에서 제거되는 deps를 본 다음 pkg> undo로 변경을 되돌릴 수 있어요.
로딩 시간이 컴파일을 하는 느린 __init__() 메서드가 지배한다면, 무엇이 컴파일되고 있는지 식별하는 장황한 방법 하나는 --trace-compile=stderr --trace-compile-timing julia 인자를 사용하는 거예요. 메서드가 컴파일될 때마다 precompile 문을 컴파일 시간과 함께 보고해줘요. InteractiveUtils 매크로 @trace_compile은 특정 호출에 대해 그 인자들을 활성화하는 방법을 제공해요. 그래서 완전한 보고를 위한 호출은 이렇게 생겼어요.
julia> @time @time_imports @trace_compile using CustomPackage
...
--startup-file=no를 주목하세요. 이는 startup.jl에 있을 수 있는 패키지들로부터 테스트를 격리하는 데 도움이 돼요.
재컴파일의 이유에 대한 더 많은 분석은 SnoopCompile 패키지로 할 수 있어요.
표현식 평가 추적하기 (Tracing expression evaluation)
테스트나 스크립트 실행 중 어떤 코드가 평가되는지 이해해야 한다면, --trace-eval 커맨드라인 옵션이나 Base.TRACE_EVAL 전역 제어를 사용해 평가되는 가장 바깥 표현식(최상위 문장)을 추적할 수 있어요. 이는 함수 호출이나 코드 블록의 내용을 개별적으로 보고하지 않는다는 점에 주의하세요.
# Show only location information during evaluation
julia --trace-eval=loc script.jl
# Show full expressions being evaluated
julia --trace-eval=full script.jl
이걸 프로그램적으로도 제어할 수 있어요.
# Enable full expression tracing
Base.TRACE_EVAL = :full
# Show only locations
Base.TRACE_EVAL = :loc
# Disable tracing
Base.TRACE_EVAL = :no
# Reset to use command-line setting
Base.TRACE_EVAL = nothing
프리컴파일 시간 줄이기 (Reducing precompilation time)
패키지 프리컴파일이 오래 걸린다면, 다음 내부를 설정한 다음 프리컴파일하는 옵션이 있어요.
julia> Base.PRECOMPILE_TRACE_COMPILE[] = "stderr"
pkg> precompile
이것은 프리컴파일 프로세스 자체에 --trace-compile=stderr --trace-compile-timing을 설정하는 효과가 있어서, 어떤 메서드가 프리컴파일되고 각각 얼마나 걸렸는지 보여줘요.
외부 프로파일러 Tracy로 프리컴파일 프로세스를 프로파일링과 같은 프로파일링 옵션도 있어요.
기타 (Miscellaneous)
미세 조정 (Tweaks)
빡빡한 내부 루프에 도움이 될 수 있는 몇 가지 사소한 점들이에요.
- 불필요한 배열을 피하세요. 예를 들어
sum([x,y,z])대신x+y+z를 쓰세요. - 복소수
z에 대해 [abs(z)^2](../../base/math/#Base.:^-Tuple{Number, Number}) 대신abs2(z)를 사용하세요. 일반적으로 복소수 인자에 대해abs대신abs2를 사용하도록 코드를 재작성하세요. - 정수의 절단(truncating) 나눗셈에는
trunc(x/y)대신div(x,y)를,floor(x/y)대신fld(x,y)를,ceil(x/y)대신cld(x,y)를 사용하세요.
비추천(deprecation) 경고를 고치세요 (Fix deprecation warnings)
비추천된 함수는 관련 경고를 한 번만 출력하기 위해 내부적으로 조회를 수행해요. 이 추가 조회는 상당한 속도 저하를 일으킬 수 있으므로, 비추천 함수의 모든 사용은 경고가 제안하는 대로 수정해야 해요.
성능 어노테이션 (Performance Annotations)
때로는 특정 프로그램 속성을 약속함으로써 더 나은 최적화를 활성화할 수 있어요.
@inbounds를 사용해 표현식 안의 배열 경계 검사를 제거해요. 이걸 하기 전에 확실히 해야 해요. 인덱스가 경계를 벗어나면 크래시나 조용한 손상이 발생할 수 있어요.@fastmath를 사용해 실수에는 올바르지만 IEEE 숫자에는 차이를 일으키는 부동소수점 최적화를 허용해요. 이걸 할 때 주의하세요. 숫자 결과를 바꿀 수 있으니까요. 이는 clang의-ffast-math옵션에 해당해요.for루프 앞에@simd를 작성해 반복이 독립적이고 재정렬될 수 있음을 약속해요. 많은 경우 Julia는@simd매크로 없이도 코드를 자동으로 벡터화할 수 있다는 점에 주의하세요.@simd는 부동소수점 재결합을 허용하거나 의존적인 메모리 접근을 무시하는(@simd ivdep) 경우처럼, 그런 변환이 달리 불법인 경우에만 유익해요. 다시 말하지만,@simd를 주장할 때는 아주 조심해야 해요. 반복이 의존적인 루프에 잘못 주석을 달면 예상치 못한 결과를 초래할 수 있으니까요. 특히 일부AbstractArray하위 타입의setindex!는 본질적으로 반복 순서에 의존한다는 점을 주목하세요. 이 기능은 실험적이며 미래 버전의 Julia에서 바뀌거나 사라질 수 있어요.
인덱싱에 1:n을 사용하는 흔한 관용구는 배열이 비관례적인 인덱싱을 사용하면 안전하지 않고, 경계 검사가 꺼져 있으면 세그멘테이션 오류를 일으킬 수 있어요. 대신 LinearIndices(x)나 eachindex(x)를 사용하세요 (사용자 정의 인덱스를 가진 배열도 참고).
참고: @simd는 가장 안쪽 for 루프 바로 앞에 놓아야 하지만, @inbounds와 @fastmath는 단일 표현식이나 중첩된 코드 블록 안에 나타나는 모든 표현식에 적용할 수 있어요. 예를 들어 @inbounds begin 또는 @inbounds for ...처럼요.
@inbounds와 @simd 마크업을 모두 쓴 예시가 있어요 (여기서는 @noinline을 사용해 옵티마이저가 너무 똑똑해져서 벤치마크를 망치지 못하게 해요).
@noinline function inner(x, y)
s = zero(eltype(x))
for i in eachindex(x, y)
@inbounds s += x[i]*y[i]
end
return s
end
@noinline function innersimd(x, y)
s = zero(eltype(x))
@simd for i in eachindex(x, y)
@inbounds s += x[i] * y[i]
end
return s
end
function timeit(n, reps)
x = rand(Float32, n)
y = rand(Float32, n)
s = zero(Float64)
time = @elapsed for j in 1:reps
s += inner(x, y)
end
println("GFlop/sec = ", 2n*reps / time*1E-9)
time = @elapsed for j in 1:reps
s += innersimd(x, y)
end
println("GFlop/sec (SIMD) = ", 2n*reps / time*1E-9)
end
timeit(1000, 1000)
2.4GHz Intel Core i5 프로세서를 가진 컴퓨터에서는 이 결과가 나와요.
GFlop/sec = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018
(GFlop/sec는 성능을 측정하며, 클수록 좋아요.)
세 종류의 마크업을 모두 쓴 예시가 있어요. 이 프로그램은 먼저 1차원 배열의 유한 차분(finite difference)을 계산한 다음, 결과의 L2-노름(norm)을 평가해요.
function init!(u::Vector)
n = length(u)
dx = 1.0 / (n-1)
@fastmath @inbounds @simd for i in 1:n #by asserting that `u` is a `Vector` we can assume it has 1-based indexing
u[i] = sin(2pi*dx*i)
end
end
function deriv!(u::Vector, du)
n = length(u)
dx = 1.0 / (n-1)
@fastmath @inbounds du[1] = (u[2] - u[1]) / dx
@fastmath @inbounds @simd for i in 2:n-1
du[i] = (u[i+1] - u[i-1]) / (2*dx)
end
@fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end
function mynorm(u::Vector)
n = length(u)
T = eltype(u)
s = zero(T)
@fastmath @inbounds @simd for i in 1:n
s += u[i]^2
end
@fastmath @inbounds return sqrt(s)
end
function main()
n = 2000
u = Vector{Float64}(undef, n)
init!(u)
du = similar(u)
deriv!(u, du)
nu = mynorm(du)
@time for i in 1:10^6
deriv!(u, du)
nu = mynorm(du)
end
println(nu)
end
main()
2.7 GHz Intel Core i7 프로세서를 가진 컴퓨터에서는 이 결과가 나와요.
$ julia wave.jl;
1.207814709 seconds
4.443986180758249
$ julia --math-mode=ieee wave.jl;
4.487083643 seconds
4.443986180758249
여기서 --math-mode=ieee 옵션은 @fastmath 매크로를 비활성화해서 결과를 비교할 수 있게 해 줘요.
이 경우 @fastmath로 인한 속도 향상은 약 3.7배예요. 이건 비정상적으로 큰데, 일반적으로 속도 향상은 더 작아요. (이 특별한 예제에서는 벤치마크의 작업 세트가 프로세서의 L1 캐시에 맞을 만큼 작아서 메모리 접근 지연이 역할을 하지 않고, 계산 시간이 CPU 사용에 의해 지배돼요. 많은 실제 프로그램에서는 그렇지 않아요.) 또한 이 경우 이 최적화는 결과를 바꾸지 않아요—일반적으로 결과는 약간 달라져요. 어떤 경우, 특히 숫자적으로 불안정한 알고리즘에서는 결과가 아주 많이 달라질 수 있어요.
@fastmath 어노테이션은 부동소수점 표현식을 재배열해요. 예를 들어 평가 순서를 바꾸거나, 특정 특수 경우(inf, nan)가 발생할 수 없다고 가정해요. 이 경우(그리고 이 특별한 컴퓨터에서) 주요 차이는 deriv 함수의 1 / (2*dx) 표현식이 루프 밖으로 끌어올려지는(즉 루프 밖에서 계산되는) 것이며, 마치 idx = 1 / (2*dx)라고 쓴 것처럼요. 루프 안에서 ... / (2*dx) 표현식은 ... * idx가 되고, 이는 평가하기 훨씬 빠르죠. 물론 컴파일러가 적용하는 실제 최적화와 결과 속도 향상 모두 하드웨어에 크게 의존해요. Julia의 code_native 함수로 생성된 코드의 변화를 검사할 수 있어요.
@fastmath는 또한 계산 중에 NaN이 발생하지 않는다고 가정하는데, 이는 놀라운 동작을 일으킬 수 있어요.
julia> f(x) = isnan(x);
julia> f(NaN)
true
julia> f_fast(x) = @fastmath isnan(x);
julia> f_fast(NaN)
false
서브노멀 숫자를 0으로 취급하세요 (Treat Subnormal Numbers as Zeros)
서브노멀(subnormal) 숫자는, 예전에는 비정규(denormal) 숫자라고 불렸는데, 많은 맥락에서 유용하지만 일부 하드웨어에서 성능 패널티를 초래해요. set_zero_subnormals(true) 호출은 부동소수점 연산이 서브노멀 입력이나 출력을 0으로 취급할 수 있게 허가하며, 이는 일부 하드웨어에서 성능을 개선할 수 있어요. set_zero_subnormals(false) 호출은 서브노멀 숫자에 대해 엄격한 IEEE 동작을 강제해요.
아래는 서브노멀이 일부 하드웨어에서 성능에 눈에 띄게 영향을 미치는 예시예요.
function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
@assert length(a)==length(b)
n = length(b)
b[1] = 1 # Boundary condition
for i=2:n-1
b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
end
b[n] = 0 # Boundary condition
end
function heatflow(a::Vector{T}, nstep::Integer) where T
b = similar(a)
for t=1:div(nstep,2) # Assume nstep is even
timestep(b,a,T(0.1))
timestep(a,b,T(0.1))
end
end
heatflow(zeros(Float32,10),2) # Force compilation
for trial=1:6
a = zeros(Float32,1000)
set_zero_subnormals(iseven(trial)) # Odd trials use strict IEEE arithmetic
@time heatflow(a,1000)
end
이것은 대략 다음과 같은 출력을 줘요.
0.002202 seconds (1 allocation: 4.063 KiB)
0.001502 seconds (1 allocation: 4.063 KiB)
0.002139 seconds (1 allocation: 4.063 KiB)
0.001454 seconds (1 allocation: 4.063 KiB)
0.002115 seconds (1 allocation: 4.063 KiB)
0.001455 seconds (1 allocation: 4.063 KiB)
짝수 반복마다 훨씬 빠른 점에 주목하세요.
이 예제는 많은 서브노멀 숫자를 생성하는데, a의 값들이 시간이 지나면서 천천히 평평해지는 지수적으로 감소하는 곡선이 되기 때문이에요.
서브노멀을 0으로 취급하는 것은 신중하게 사용해야 해요. x-y == 0이 x == y를 뜻하는 것 같은 몇몇 항등식을 깨뜨리기 때문이에요.
julia> x = 3f-38; y = 2f-38;
julia> set_zero_subnormals(true); (x - y, x == y)
(0.0f0, false)
julia> set_zero_subnormals(false); (x - y, x == y)
(1.0000001f-38, false)
어떤 애플리케이션에서는 서브노멀 숫자를 0으로 만드는 대신, 아주 작은 노이즈를 주입하는 대안이 있어요. 예를 들어 a를 0으로 초기화하는 대신 이렇게 초기화하세요.
a = rand(Float32,1000) * 1.f-9
I/O에 문자열 보간을 피하세요 (Avoid string interpolation for I/O)
파일(또는 다른 I/O 장치)에 데이터를 쓸 때, 중간 문자열을 추가로 만드는 것은 오버헤드의 원인이에요. 이렇게 쓰는 대신:
println(file, "$a $b")
이렇게 쓰세요.
println(file, a, " ", b)
첫 번째 버전의 코드는 문자열을 만든 다음 파일에 쓰는 반면, 두 번째 버전은 값을 파일에 직접 써요. 또한 어떤 경우에는 문자열 보간이 읽기 더 어려울 수 있다는 점도 주목하세요.
println(file, "$(f(a))$(f(b))")
대신:
println(file, f(a), f(b))
성급한 문자열 구체화(materialization)를 피하세요 (Avoid eager string materialization)
객체의 문자열 표현이 조건부로만 필요한 설정(예: 오류 경로나 deprecation 같은 조건부 경고)에서는, 문자열을 성급히 구체화하는 오버헤드를 피하는 것이 좋아요. Julia 1.8부터는 LazyString과 해당 문자열 매크로 @lazy_str로 이를 달성할 수 있어요.
예를 들어, 이렇게 쓰는 대신:
Base.depwarn("`foo` is deprecated for type $(typeof(x))", :bar)
이렇게 쓰세요.
Base.depwarn(lazy"`foo` is deprecated for type $(typeof(x))", :bar)
또는 매크로 없는 동등한 버전:
Base.depwarn(LazyString("`foo` is deprecated for type ", typeof(x)), :bar)
이 접근 방식을 통해 보간된 문자열은 실제로 표시될 때만 구성돼요.
병렬 실행 중 네트워크 I/O 최적화하기 (Optimize network I/O during parallel execution)
병렬로 원격 함수를 실행할 때:
using Distributed
responses = Vector{Any}(undef, nworkers())
@sync begin
for (idx, pid) in enumerate(workers())
Threads.@spawn responses[idx] = remotecall_fetch(foo, pid, args...)
end
end
이것은 이 방식보다 빨라요:
using Distributed
refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]
전자는 각 워커에 단일 네트워크 왕복(round-trip)으로 이어지지만, 후자는 네트워크 호출 두 번—첫 번째는 @spawnat 때문이고 두 번째는 fetch(또는 wait) 때문이에요. fetch/wait도 직렬로 실행되어 전체적으로 더 나쁜 성능을 초래해요.
변경 가능한 산술 타입의 할당을 더 제어하려면 MutableArithmetics를 사용하세요 (Use MutableArithmetics for more control over allocation for mutable arithmetic types)
BigInt이나 BigFloat 같은 일부 Number 하위 타입은 [mutable struct](../../base/base/#mutable struct) 타입으로 구현되거나, 변경 가능한 구성 요소를 가질 수 있어요. Julia Base의 산술 인터페이스는 이런 경우 보통 편의를 효율보다 우선시하므로, 그걸 순진하게 사용하면 최적이 아닌 성능이 나올 수 있어요. 반면 MutableArithmetics 패키지의 추상화는 그런 타입의 변경 가능성을 활용해, 필요한 만큼만 할당하는 빠른 코드를 작성할 수 있게 해 줘요. MutableArithmetics는 필요할 때 변경 가능한 산술 타입의 값을 명시적으로 복사하는 것도 가능하게 해 줘요. MutableArithmetics는 사용자 패키지이며 Julia 프로젝트와 제휴하지 않아요.