Julia 성능 팁 (Julia Performace Tips)

YebinPapa·2022년 9월 27일
0

Julia documents

목록 보기
1/5

ver 2022.09.27

주의사항

  • Julia Performace Tips 의 번역 & 요약 등등등.
  • 개인용으로 작성한 문서임. 보는것은 자유지만 오탈자 등의 오류는 장담 못함.
  • 계속 개선시키기는 할 것임


다음에 나오는 섹션들에서는 Julia 코드를 가능한한 빠르게 실행시키는데 도움이 되는 몇 가지 기술에 대해 간략하게 검토한다.


성능에 중요한 코드는 함수 내에 위치해야 한다

성능에 중요한 코드는 함수 안에 위치해야 한다. Julia 컴파일러의 작동 방식때문에 함수 내부의 코드는 최상위 레벨 코드보다 훨씬 빠르다.

함수를 사용하는 것은 성능에만 중요한 것은 아니다 : 함수는 더 재사용 가능하고 테스트 가능하며, 수행 단계와 입출력을 명확하게 한다. 단순한 스크립트가 아닌 함수를 작성하라 는 Julia's Styleguide의 권장 사항이다.

함수는 전역 변수를 직접적으로 연산하는 대신에 인자를 받아야 한다. 아래를 보라. (see the next point.)


타입이 지정되지 않은 전역변수를 피할것

타입이 지정되지 않은 전역변수는 값을 가질 수도 있으며, 따라서 그 값의 타입도 가질수 있고, 언제든 변할 수 있다. 이 점이 컴파일러가 전역변수를 사용하는 코드를 최적화하는것을 어렵게 한다. 이것은 타입이 지정된 변수에도 적용된다. 즉 타입은 전체 영역에서 alias 된다.(This also applies to type-valued variables, i.e. type aliases on the global level.) 변수는 local 이어야 한다. 즉 불가피한 경우를 제외하고 함수의 인자로 전달되어야 한다.

전역 명칭은 대부분 상수이며, 그렇게 선언하는 것이 성능을 향상시킨다.

const DEFAULT_VAL = 0

전역변수가 항상 같은 타입이라면, 그 타입이 지정(annotaed) 되어야 한다

타입이 명시되지 않은 전역변수는, 그것의 사용 시점에 지정한다면 최적화 될 수 있다:

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

는 아래와 같다.

julia> global x = 1.0

따라서 앞서 논의된 성능에 관련된 사항은 여기에 적용된다.


@time 을 이용하여 성능을 측정하고 메모리 할당에 유의할 것

@time 매크로는 성능을 측정하는 유용한 도구이다. 앞서 논의했던 전역변수에 대한 보기를 타입 지정(type annotation)을 제거하고 여기서 반복 해 보자.

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 비트 부동소수 벡터의 모든 성분을 더하는 계산을 하는 것이며, 따라서 메모리를 할당할 필요가 없다.(최소한 @time 이 리포트하는 heap 영역에서는 그렇다.)

예상치 못한 메모리 할당은 대부분의 경우 코드에 문제가 있다는 표시이며, 일반적으로 타입-안정성이나 다수의 작은 임시 배열을 생성하는것에 관련된 문제이다. 결론적으로, 할당 뿐만아니라 함수를 위해 작성된 코드도 최적화와는 거리가 멀다. 이러한 지표를 진지하게 여기고 아래의 조언을 따르라.

만약 우리가 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 allocations@time 매크로를 전역 범위에서 실행시키며 발생한 것이다. 만약 이 매크로를 함수 내부에서 실행시킨다면 우리는 어떤 할당도 발생하지 않는다는 것을 확인 할 수 있다.

julia> time_sum(x) = @time sum_arg(x);

julia> time_sum(x)
  0.000002 seconds
523.0007221951678

몇몇 경우에서는 당신의 함수가 그 연산의 일부로서 메모리를 할당할 필요가 있을수 있으며, 이는 앞서의 간단한 상황을 복잡하게 한다. 이 경우에는 문제를 진단하기 위해 아래의 tools 가운데 하나를 사용하거나, 알고리즘적인 부분과 할당 부분을 분리하는 함수를 작성하는 것을 고려하라( Pre-allocationg outputs 을 참고하라)

주의
고급 벤치마킹을 위해 BenchmarkTools.jl 사용을 고려해보라. 무엇보다도 이것은 잡음을 줄이기 위해 여러번 함수를 평가한다.


Tools

Julia 와 Julia 패키지 생태계는 당신이 문제를 진단하고 코드의 성능을 향상시킬 여러 도구들을 포함한다.

  • Profiling 은 당신의 코드를 실행시키는 성능을 측정하며 병목 지점을 찾아준다. 복잡한 프로젝트용으로는 ProfileView 패키지가 당신의 프로파일링 결과를 시각화하는데 도움이 될 것이다.
  • Traceur 패키지는 당신 코드내의 일반적인 성능 문제를 발견하는데 도움이 된다.
  • @time 이나 @allocated, 혹은 프로파일러가 (가비지 콜렉션 루틴을 호출하여) 뜻밖의 큰 메모리 할당을 보고했다면 이것은 당신의 코드에 문제가 있다는 암시이다. 당신이 이 할당에 별다른 이유를 찾지 못했다면 타입 문제를 의심해보라. 당신은 Julia 를 --track-allocation=user 옵션을 주어 실행하고 이 결과 생기는 *.mem 파일을 확인해서 어디서 이런 할당이 발생하는지 볼 수 있다. Memory allocation analysis 를 읽어보라.
  • @code warntype 은 타입 불확실성을 발생시키는 표현을 찾는데 도움이 되는 representation 을 생성한다. 아래의 @code_warntype 을 읽어보라.

추상 타입 매개변수를 포함하는 Container 를 피하라.

배열(Array)을 포함하여 매개화된 타입을 가지고 일할 때, 가능한 한 추상 타입으로 매개화하는것을 피하는 것이 최선이다.

다음을 보라.

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 와 같은 값들만 a 에 저장하는것을 허용한다면 이들은 좀 더 효율적으로 저장될 수 있다:

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 로 변환하여 효율적으로 다뤄질 수 있는 연속적인 64 비트 부동소수값의 블록으로 저장된다.

만약 추상 타입 컨테이너를 사용할 수 밖에 없다면 runtime 의 타입 확인을 피하기 위해 Any 로 매개화하는게 좋을 때도 있다. 예를 들자면 IdDict{Any, Any}idDict{Type, Vector} 보다 성능이 좋다.

Parametric Types 의 논의를 참고하라.


타입 선언

타입 선언이 선택 사항인 많은 언어에서는, 선언을 하는 것이 빠르게 동작하는 코드를 만드는 지름길이다. Julia 에서는 그렇지 않다. Julia에서는 컴파일러가 보통 모든 함수 인자, 지역변수와 표현(expression)의 타입을 알고 있다. 그러나 그러나 몇몇 특정 상황에서는 선언이 도움이 된다.


추상 타입을 가진 필드를 피하라

추상 타입은 그 필드의 타입을 특정하지 않고 선언 될 수 있다.

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

비록 bc 에 대한 메모리 내부에서의 내재적인 데이터 표현이 아주 다를지라도, 그 둘의 값은 같은 타입이다. a 필드에 수를 저장하더라도, UInt8 의 메모리 표현과 Float64의 메모리 표현이 다르다는 사실은 CPU가 두개의 서로 다른 종류의 명령어(instructions)를 사용하여 다룰 필요가 있다는 것을 의미한다. 타입으로부터 필요한 정보를 얻을 수 없으므로 이 결정은 런타임에 이루어진다. 이것이 성능을 떨어트린다.

당신은 a의 타입을 선언함으로서 개선시킬 수 있다. 여기서, 우리는 a 가 여러 타입들 중 아무거나 하나의 타입이 될 수 있는 경우에 초점을 두자. 이 경우 매개변수를 쓰는 것이 자연스러운 해법이다. 예를 들어:

julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

이것은 아래보다 좋은 선택이다.

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

왜냐면 첫번째 버젼은 wrapper 객체의 타입에서 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 의 type 은 변할 수 없다.

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

m 의 타입으로부터 m.a 의 타입을 알 수 있다는 사실과, 이 타입이 mid-function 을 변경시킬수 없다는 사실은 컴파일러가 t 와 같은 객체가 아닌 m 과 같은 객체에 대해 고도로 최적화된 코드를 생성하도록 해 준다.

물론, 이 모든것은 우리가 m 을 구체적 타입(concrete type)을 이용하여 m 을 구성했을 경우에만 사실이다. 이것은 abstract 타입을 이용하여 명시적으로 구성했을 경우에는 사실이 아니다.

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}})

를 사용하여 간단한 함수를 위해 생성된 대량의 코드를 비교하는 것은 매우 교육적으로 유익하다.1

결과가 너무 길어서 여기에 제시하지 않지만 여러분이 직접 해보길 바란다. 첫번째 경우는 타입이 완전히 지정되었으므로 컴파일러가 실행시간에 타입을 결정하는 코드를 만들 필요가 전혀 없다. 결과적으로 짧고 빠른 코드가 된다.

전적으로 매개화되지 않은 타입(not-fully-parameterized type)은 추상 타입처럼 행동한다. 예를 들어, 전적으로 특정된 Array{T, n} 은 concrete 하지만 파라미터가 없는 Array 자체는 concrete 하지 않다.

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} 으로 선언하거나 이나 a::A 으로 선언하며 {T, N} 이나 AMyType 의 파라미터로 선언하는 것이 낫다.


추상 컨테이너를 필드로 갖는 것을 피하라.

동일한 최선의 방법은 컨테이너 타입에도 적용된다:

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)

이렇게 컴파일러가 모든 경우에서 최적화된 코드를 생성하도록 하면서, 모든 것을 단순하게 유지할 수 있다.

그러나, MySimpleContianera 필드의 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

타입이 지정되지 않은 위치로부터 값을 지정하기

많은 경우 임의의 타입을 저장 할 수 있는 자료구조(Array{Any} 타입의 배열) 를 이용하는것이 편하다. 그러나 이런 구조들 가운데 하나를 이용하면서 동시에 원소의 타입을 안다면, 컴파일러와 이 지식을 공유하는 것이 도움이 된다:

function foo(a::Array{Any,1})
    x = a[1]::Int32
    b = x+1
    ...
end

여기서 우리는 a 의 첫번째 원소가 Int32 타입이라는 것을 안다. 이렇게 지정하게 되면, 그 값이 기대했던 타입이 아닐 때 런타임 에러를 발생시키며, 잠재적으로 어떤 버그를 미리 발견할수도 있다.

a[1] 의 타입을 정확히 알지 못하는 경우 xx = convert(Int32, a[1]) 로 선언 될 수 있다. convert 함수를 사용하게 되면 a[1]Int32 로 변환될 수 있는 어떤 객체(예를 들면 UInt8)도 될 수 있으며, 타입 요구 조건을 완하함으로서 코드의 일반성을 향상시킨다. convert 함수 자체가 타입 안정성을 확보하기 위해 문맥상에서(in the context) 타입 지정을 필요로 한다는 것에 유의하라. 이는 컴파일러가 함수의 모든 인자들의 타입을 알지 못하면, convert 함수에 있어서 조차 함수의 리턴 값의 타입을 추론하지 못하기 때문이다.

만약 타입이 추상적 타입이거나 런타임에 구성된다면 타입 지정은 성능을 향상시킬수 없다.(실제로 저하시킬수 있다.) 이는 컴파일러가 뒤따르는 코드를 특정하는데 타입 지정을 사용할 수 없으며, 따라서 타입을 확인하는데 시간이 걸리기 때문이다. 예를 들어 다음 코드 :

function nr(a, prec)
    ctype = prec == 32 ? Float32 : Float64
    b = Complex{ctype}(a)
    c = (b + 1.0f0)::Complex{ctype}
    abs(c)
end

c 에 대한 지정은 성능을 해친다. 런타임에 구성되는 타입에 관련된 성능 좋은 코드를 작성하기 위해서는 아래에 논의될 function-barrier technique 를 사용하고, 구성되는 타입이 커널 함수의 인자에 나타나서, 커널 작동이 컴파일러에 의해 제대로 특정되도록 해야 한다. 예를 들어, 위의 코드에서 b 가 구성되자 마자 다른 함수 k (the kernel) 에 전달 될 수 있다. 예를 들어 함수 kb 를 타입 파라미터 T 에 대해 Complex{T} 로 선언하였다면, k 에서의 할당문(assignment statement) 에서 나타나는 아래와 같은 타입 지정

c = (b + 1.0f0)::Complex{T}

은 성능을 해치지 않는데(도움도 되지 않지만) 이는 컴파일러가 c 의 타입을 k가 컴파일 될 때 결정하기 때문이다.


언제 Julia가 specializing(타입 특정) 을 피하는지 숙지하라.

경험적으로(As an heuristic) Julia는 Type, FunctionVararg의 세 가지 특정한 경우에서 인자 타입 매개변수를 자동으로 특정하는것을 피한다. 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 가 보통은 메쏘드 호츨에서 특정하지 않더라도 항상 타입을 특정한 코드를 보여준다. 함수 인자 타입이 변했을 때 타입 특정을 생성하는지 확인하고 싶다면, 즉 (@which f(...)).specializations 가 궁금한 인자에 대해 타입 특정을 포함하고 있는지 알고 싶다면, method internals 를 확인할 필요가 있다.


함수를 다중 정의로 분리하라

함수를 많은 작은 정의들로 작성하는 것은 컴파일러가 가장 적절한 코드를 호출하거나, inline 처리까지도 하도록 해 준다.

아래는 다중 정의로 작성되어야만 하는 복합 함수(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

이 코드는 더 간결하교 효율적으로 고쳐질 수 있다:

norm(x::Vector) = sqrt(real(dot(x, x)))
norm(A::Matrix) = maximum(svdvals(A))

그러나 mynorm 예제로 작성된 코드에서 컴파일러가 죽은 분기를 최적화하는 제거하는 데(optimizing away) 매우 효율적임을 주목해야 한다.


"Type-Stable" 함수를 작성할 것

가능하다면 함수가 항상 같은 타입을 반환하도록 보장하는 것이 유용하다. 다음 코드를 살펴보자.

pos(x) = x < 0 ? 0 : x

비록 이것이 무해해 보이겠지만, 문제는 0 은 정수이며(Int 타입) x 는 어떤 타입도 가능하다는 것이다. 따라서 x 의 값에 따라 두가지 타입중 하나를 반환할 수 있다. 이런 동작은 허용되며, 어떤 경우엔 바람직 할 수도 있다. 그러나 이것은 아래와 같이 쉽게 수정된다.

pos(x) = x < 0 ? zero(x) : x

oneunit 함수나, 더 일반적인 oftype(x, y) 가 있다. 후자는 yx의 타입으로 변화시켜 리턴한다.


변수의 타입을 변화시키는 것을 피하라.

유사한 타입 안정성(type stability) 문제는 함수 내에서 반복적으로 사용되는 변수에서도 존재한다.

function foo()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end

지역변수 x 는 정수로 시작해서 한 루프 다음에 / 연산으로 인해 부동소수가 된다. 이것은 컴파일러가 루프의 몸체를 최적화하는것을 어렵게 한다. 몇가지 가능한 수정사항이 있다.

  • xx=1.0 으로 초기화한다
  • x 를 명시적으로 x::Float64=1 로 선언한다
  • 명시적 형변환을 사용한다 : x=oneunit(Float64)
  • 첫번재 루프로 즉 x=1/rand() 로 초기화 하고 i=2:10 에 대해 반복한다.

커널 함수를 분리한다 (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의 타입을 알지 못한다(왜나면 임의로 선택되었기 때문에). 따라서 내부의 루프가 fill_two! 의 부분으로 a 의 두 타입에 대해 리컴파일 되었으므로, 두번째 버젼이 일반적으로 빠르다.

두번째 폼이 대부분 더 좋은 스타일이며 코드를 재사용하는데도 좋다.

이 패턴은 Julia Base 에서 상당수 사용된다. 예를 들어 abstractarray.jlvcathcat 을 보거나, fill! 함수를 찾아보면 우리의 fill_twos! 함수를 대신해서 쓸 수 있을 것이다.

strange_twos 와 같은 함수는 불확실한 타입의 데이터, 예를 들면 입력 파일로부터 정수, 소수(floats), 문자열 및 기타등을 포함한 데이터를 읽거나 할 경우와 같은 상황에서 발생한다.


매개변수로 사용되는 값을 가진 타입

축당 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

이런 방법은 잘 작동한다: 컴파일러는 (fill 함수가 채우는 함수라는 의미에서) 채워지는 값의 타입(5.0::Float64) 와 차원 ((3, 3)::NTuple{2, Int})을 알기때문에 AArray{Float64, 2} 라고 이해할 수 있다. 이것은 컴파일러가 향후 같은 함수내에서 A 를 사용하는데 더 효율적인 코드를 생성할 수 있다는 것을 의미한다.

이제 임의의 차원 3x3x... 을 갖는 배열을 생성한다고 하자; 다음과 같은 함수로 시도해 볼 수 있을것이다.

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 은 정수 타입의 값이며, 타입 추론은 앞으로 이 겂이 어떻게 될지 추정하지 않으며 할수도 없다. 이것은 이 함수의 출력값을 사용하는 코드가 보수적이어야 하며, A 에 접근 할 때마다 타입을 확인해야 한다는 것을 의미한다; 이러한 코드는 매우 느릴것이다.

이러한 문제를 해결하는 가장 좋은 방법은 function-barrier technique 을 사용하는 것이다. 그러나 몇몇 경우에서는 당신이 타입 불안정성도 같이 제거하기를 원할 수도 있다. 이런 경우는 차원을 매개변수로 전달하는것도 (예를 들자면 Val{T}() 를 이용하여) 한 방법이다: ("Value types"를 보라)

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{N::Int} 객체를 두번째 매개변수로 받는 특별한 ntuple 버젼이 존재한다. 여기서 N 은 타입 매개변수로 그 값을 컴파일러에게 알려준다. 결론적으로 이 버젼의 array3 는 컴파일러가 리턴 타입을 알게 해 준다.

그러나 이런 기술을 사용하는 것은 아주 민감할 수 있다. 예를 들어 array3 를 아래와 같이 함수에서 호출한다면 이 방법은 소용 없을 것이다.

function call_array3(fillval, n)
    A = array3(fillval, Val(n))
end

여기서 똑같은 문제가 발생했다: 컴파일러는 n 이 무엇인지 예상할 수 없으며, 따라서 Val(n) 의 타입도 알수가 없다. Val 을 사용하려고 하면서, 부정확하게 사용한다면 많은 경우 성능이 악화된다. (당신이 Val 과 function-barrier 트릭을 효율적으로 결합하여 커널 함수를 더 효율적으로 작성했을 때에만 위의 코드가 사용되어야 한다.)

Val 의 올바른 사용 예는 다음과 같다.

function filter3(A::AbstractArray{T,N}) where {T,N}
    kernel = array3(1, Val(N))
    filter(A, kernel)
end

이 예에서 N 은 매개변수로 전달되었고 그 "값"이 컴파일러에게 알려졌다. T 가 hard-coded/literal (Val(3)) 이거나 타입영역에서 이미 특정되었을 때에만 Val(T) 이 제대로 동작한다.


Multiple dispatch 의 위험한 오용 (혹은 types with values-as-parameters 에 대한 추가사항)

일단 다중 디스패치(multiple dispatch) 를 이해하게 되었다면, 다소 극단적으로 모든 곳에 사용하고자 하는 자연스러운 경항이 있다. 예를 들어 다중 디스패치를 이용하여 정보를 저장하고자 한다면,

struct Car{Make, Model}
    year::Int
    ...more fields...
end

라고 작성하고 객체애 대해 Car{:Honda,:Accord}(year, args...) 라고 디스패치 한다고 하자.

이것은 아래의 조건이 충족될 때 사용할 만 하다.

  • 각각의 Car 에 대해 CPU intensive 한 프로세스를 수행해야 하며, 컴파일 타임에 MakeModel 을 알 경우에 훨씬 효율적이 되고, 서로 다른 MakeModel 이 그다지 많지 않을 때.
  • 같은 Car 의 타입으로 이루어진 동종의(homogeneous) 목록으로 작업을 수행하여, (예를 들자면) Array{Car{:Honda, :Accord}, N} 의 배열에 그 목록을 모두 저장 할 수 있을 때.

후자의 조건이 충종된다면, 이런 동종의 배열을 처리하는 함수는 생산적으로 특정될 수 있다: 이후 Julia 는 각 원소들의 타입을 알기 때문에(콘테이너 안의 모든 객체는 동일한 구체적인 타입이다) 그 함수가 컴파일 될 때 (런타임에서의 타입 확인을 불필요게 하며) 올바른 메쏘드를 찾아 낼 수 있다. 따라서 전체 리스트를 처리하는 효율적인 코드를 만들어낸다.

이 조건들이 충족되지 못하면 이득을 볼 것이 없을 것이며, 혹은 결과적으로 발생하는 "combinational explosion of types" 는 역효과를 낳을 수도 있다. 만약 items[i+1]item[i] 와 다른 타입이라면 Julia는 런타임에 타입을 조회할 것이고 메쏘드 테이블에서 적절한 메쏘드를 조회할 것이며, 타입 교차(type intersection) 을 통해 일치하는 메쏘드를 찾고, 그것이 이미 JIT 컴파일 되었는지 확인하여 아직 컴파일 되지 않았다면 컴파일하고, 그것을 호출한다. 본질적으로 (Julia 의) 전체 타입 시스템 및 JIT 컴파일 장치가 당신의 코드에서 switch 문 또는 사전 조회(dictionary lookup)와 동등한 것을 기본적으로 실행하도록 요청하는 것이다.

(1) 타입 디스패치, (2) dictionary lookup, (3) a "switch" statement 를 비교하는 몇몇 런타임 벤치마크가 메일링 리스트 에 기록되어 있다.

런타임보다 컴파일 타임에서의 효과가 더 안좋을 수도 있다: Julia 는 각각의 Car{Make, Model} 에 대해 특정 함수를 컴파일할 것이다; 만약 이런 타입이 수백 수천개라면 이런 객체를 매개변수로 받아들이는 모든 함수 (당신 스스로 작성한 get_year 류의 함수부터 Julia Base 의 generic push! 함수까지) 에 대해 컴파일한 수백 수천개의 변형을 갖게 될 것이다. 각각의 변형은 컴파일된 코드의 케쉬 사이즈와 메쏘드들에 대한 내부적인 리스트의 길이 등을 증가시킬 것이다. Values-as-Parameters 에 대한 과도한 열광은 대량의 컴퓨팅 자원을 쉽게 낭비할 수 있다.


배열을 메모리 순, 즉 열을 따라 접근할 것

Julia의 다차원 배열은 열-우선 순서(column-major order)로 저장된다. 이는 (메모리에서) 한번에 한 열(column) 씩 차례로 저장된다는 의미이다. 이것은 vec 함수나 [:] 구문을 이용하여 아래와 같이 확인 할 수 있다. (배열이 [1 2 3 4] 순이 아닌 [1 3 2 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

이런 열-우선 관행은 포트란, 매트랩, R 을 포함하는 몇몇 언어에 공통적이다. 다른 방법은 행-우선 순서(row-major order)로 C 나 Python (numpy) 을 포함하는 다른 언어에 사용된다. 배열 순서(ordering of arrays)는 array 를 순회할 때 성능에 지대한 영향을 미칠 수 있음을 기억하라. 열-우선 배열에서 기역해야할 경험칙은 첫번째 인덱스가 가장 빨리 변한다는 것이다. 이것은 기본적으로 슬라이스 표현에서 가장 먼저 나오는 인덱스가 가장 안쪽에 위치한 루프의 인덱스 일 때 더 빠름을 의미한다. 배열을 : 로 인덱싱한다는 것은 내재적으로 특정 차원의 모든 원소를 반복적으로 순회한다는 것을 기억하라; 예를 들자면, 행보다 열을 추출하는 것이 더 빠르다.

다음의 인위적인 보기를 살펴보자. 벡터(Vector) 를 받아서 정사각행렬 을 리턴하는데, 리턴하는 행렬의 행이나 열이 입력 벡터의 복사본으로 채워지도록 하여 리턴하는 함수를 작성한다고 하자. 입력벡터의 복사본으로 채워지는 것이 행인지 열인지는 중요하지 않다고 가정하자.(코드의 나머지는 이것에 따라 쉽게 변경 될 수 있다.) 기본으로 제공되는(built-in) 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 개를 위의 함수에 대한 입력 벡터로 사용하여 각각의 수행시간을 측정해 보자.

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_colscopy_rows 보다 훨씬 빠른것애 주목하라. copy_colsMatrix 의 열 기반 메모리 배치를 고려하여 한번에 한 열씩 채우기 때문에 예상 할 수 있는 결과이다. 추가로 copy_col_rowcopy_row_col 보다 훨씬 빠른데, 이는 앞서 언급한 슬라이스 표현의 첫번째 원소가 가장 내부 깊숙히 위치한 루프에 결합되어야 한다는 경험칙을 따르기 때문이다.


출력의 사전 할당.

만약 함수의 리턴값이 배열이나 다른 복합 타입이라면 메모리를 할당해야 할 수도 있다. 불행하게도 할당과 그 역인 가비지 콜렉션은 빈번히 중요한 병목지점이 된다.

때때로 출력(outputs)을 미리 할당하여(사전 할당), 함수를 호출할때마다 메모리를 할당할 필요성을 제거할수도 있다. 간단한 보기로 다음 둘을 비교해보자.

julia> function xinc(x)
           return [x, x+1, x+2]
       end;

julia> function loopinc()
           y = 0
           for i = 1:10^7
               ret = xinc(i)
               y += ret[2]
           end
           return y
       end;
julia> function xinc!(ret::AbstractVector{T}, x::T) where T
           ret[1] = x
           ret[2] = x+1
           ret[3] = x+2
           nothing
       end;

julia> function loopinc_prealloc()
           ret = Vector{Int}(undef, 3)
           y = 0
           for i = 1:10^7
               xinc!(ret, i)
               y += ret[2]
           end
           return y
       end;

수행시간 측정 결과는 다음과 같다.

julia> @time loopinc()
  0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time)
50000015000000

julia> @time loopinc_prealloc()
  0.030850 seconds (6 allocations: 288 bytes)
50000015000000

사전할당의 다른 장점들이 있는데, 예를 들면 함수 호출자가 알고리즘을 이용하여 출력 타입을 조절할 수 있다는 것이다. 위의 보기에서 우리는 우리가 원했다면 Array 가 아닌 SubArray 를 전달 할 수 있었다.

극단적인 경우, 사전할당은 당신의 코드를 추하게 만들 수도 있으므로, 성능 측정 뿐만 아니라 다른 판단도 필요하다. 그러나 (원소별로 처리하는)벡터화된 함수의 경우는 x .= f.(y) 같은 편의적 구문을 혼합된 루프(fused loops) 와 함께 임시적 배열 없이 사용 할 수 있다.(dot syntax for vectorizing functions 를 보라).

도트에 대한 추가사항 : Fuse vectorized operations

Julia 에는 어떠한 스칼라 함수도 벡터화 함수(vectorized function) 호출로 변환시키며, 연산자를 벡터화된 연산자로 변환시키는 도트 구문(dot syntax) 이 있으며, 중첩된 "도트 호출" 은 융합(fusing) 되는 특별한 성질을 지닌다 : 중첩된 호출른 구문 레벨이서 임시 배열이 할당되지 않고 단일 루프로 병합된다.2 .= 및 유사한 할당 연산자를 사용한다면, 그 결과를 사전 할당된 배열의 그 자리에(in place) 저장될 수 있다. (이전을 보라)

선형대수학적 맥락에서 이것은 vector + vectorvector * scalar 연산자가 정의되었더라도, vector .+ vectorvector .* scalar 를 사용하는 것이 유리할 수 있는데, 이것은 result loops 가 주변 계산들과 병합될 수 있기 때문이다. 예를 들어 다음 두 함수를 보자.

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

ffdot 은 같은 일을 한다. 그러나 (@. 매크로3의 도움으로 정의된) 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) 보다 10배 빠르고 메모리도 1/6 만 할당하는데 이것은 f(x)*+ 연산이 새로운 임시 배열을 할당하고 별도의 루프에서 실행되기 때문이다. (이 보기에서는 그냥 f.(x) 만 하면 fdot(x) 만큼 빠르지만 많은 상황에서 별도의 벡터화된 연산에 대한 별도의 함수를 정의하는 것 보다는 그냥 당신의 표현식에 도트를 뿌리는게 더 편하다.)


슬라이스에서 views 를 사용하기.

Julia 에서 array[1:5, :] 과같은 "slice" 표현은 (array[1:5, :]=... 와 같이 할당의 왼쪽에 위치한 경우를 제외하면. 이 경우는 array 의 부분에 대한 in-place 할당이다.) 데이터의 복사본을 만든다. 이 슬라이스에 많은 연산을 수행하는 것은 성능에 좋은데, 이는 큰 원본 배열의 인덱스로 접근하는 것보다 작은 복사본에 대해 연산하는 것이 효율적이기 때문이다. 반면에 만약 소수의 간단한 연산만을 수항한다면 할당과 복사 연산을 수행하는 비용이 중요해진다.

이에 대한 대안은 배열의 "view" 를 만드는 것이다. view 는 원본 배열의 데이터를 실제로 참조하는 배열 객체 (a SubArray) 로서, 복사본을 만들지 않는다. (만약 view 에 기록한다면 원본 데이터를 수정하는 것이다). 개별 슬라이스에 대해 view 함수룰 호출하거나, 더 간단하게는 전체 표현이나 코드의 블록 앞에 @views 를 삽입하여 view 를 만들 수 있다. 예를 들어 :

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배의 속도 향상과 메모리 할당 감소가 발생했음에 주목하라.


데이터 카피는 항상 나쁜것은 아니다.

배열은 메모리에 연속적으로 저장되며, CPU vectorization에 적합하고 캐싱(caching)으로 인해 메모리 액세스가 감소한다. 이것들은 열-우선 순서로 저장되는것치 권장되는 것과 같은 이유이다. 불규칙적인 접근 패턴과 비 연속적인 views 는 순차적이지 않은 메모리 접근으로 인해 배열에 대한 계산을 급격하게 느리게한다.

불규칙적으로 접근되는 데이터를 이에 대한 연산 이전에 인접 배열(contiguous array)로 카피하는 것은 아래의 보기와 같은 상당한 속도 향상을 이끌어 낼 수 있다. 여기서는 행렬과 벡터가 곱해지기 전에 임의로 섞여진 인덱스로 800,000 번 접근된다. views 를 일반적인 배열로 복사하는 것은 복사에 들어가는 비용을 고려하더라도 곱하기 속도를 끌어올린다.

julia> using Random

julia> x = randn(1_000_000);

julia> inds = shuffle(1:1_000_000)[1:800000];

julia> A = randn(50, 1_000_000);

julia> xtmp = zeros(800_000);

julia> Atmp = zeros(50, 800_000);

julia> @time sum(view(A, :, inds) * view(x, inds))
  0.412156 seconds (14 allocations: 960 bytes)
-4256.759568345458

julia> @time begin
           copyto!(xtmp, view(x, inds))
           copyto!(Atmp, view(A, :, inds))
           sum(Atmp * xtmp)
       end
  0.285923 seconds (14 allocations: 960 bytes)
-4256.759568345134

복사본을 위한 충분한 메모리가 있는 경우 view를 배열에 복사하는 비용은 인접 배열에서 행렬 곱셈을 수행하는 속도 향상에 의해 극복된다.


작은 크기의 벡터/행렬 연산에는 StaticArrays.jl 사용을 고려해보라.

당신이 매우 작고 (원소의 수가 100개 이하의) 크기가 고정된 (즉, 실행 이전에 크기를 알 수 있는) 배열을 다루려고 한다면 StaticArrays.jl 패키지 를 사용하고 싶어할 수도 있다. 이 패키지는 당신이 이 배열을 표현하는데 불필요한 힙(heap) 할당을 피하며, 컴파일러가 배열의 크기에 특화된 코드를 작성하도록, 즉 벡터 연산을 완전히 풀고(루프를 제거하고) 원소를 CPU 레지스터에 저장하도록 해 준다.

예를 들어, 만약 당신이 2차원 기하학에서의 계산을 하고 있다면 성분이 2개인 벡터에 대한 많은 계산을 수행할 것이다. StaticArrays.jl 의 SVector 를 사용하면 편리한 벡터 노테이션과 벡터 vu 에 대한 norm(3v-w) 와 같은 연산을 수행하며, 이때 컴파일러는 @inbounds hypot(3v[1]-w[1], 3v[2]-w[2]) 와 동등한 최소한의 계산을 하도록 코드를 전개한다.


입출력에서 문자열 interpolation 을 피하라.

파일이나 입출력 장체에 데이터를 쓸 때 추가적인 intermadiate strings 를 만드는것은 오버헤드의 원인이다.

println(file, "$a $b")

위의 코드 대신에 아래의 코드를 사용하라.

println(file, a, " ", b)

코드의 첫번째 버젼은 문자열을 만들고 그것을 파일에 쓰지만, 두번째 버젼은 값을 직접적으로 파일에 쓴다. 몇몇 경우에서는 문자열 인터폴레이션이 읽기 힘들다는 것에 역시 주의하라. 예를 들어,

println(file, "$(f(a))$(f(b))")

println(file, f(a), f(b))

를 비교해보라.


병렬 처리동안의 네트워크 입출력을 최적화하라.

네트워크 병렬 처리는 잘 몰라서..

병렬로 원격 함수를 처리한다고 하자:

using Distributed

responses = Vector{Any}(undef, nworkers())
@sync begin
    for (idx, pid) in enumerate(workers())
        @async 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]

The former results in a single network round-trip to every worker, while the latter results in two network calls - first by the @spawnat and the second due to the fetch (or even a wait). The fetch/wait is also being executed serially resulting in an overall poorer performance.


사용 중단(deprecation) 경고에 따른 수정.

사용이 중단된(deprecated) 함수는 내부적으로 관련된 경고를 출력하기 위해 한차례 조회한다. 이 여분의 조회는 중대한 지연의 원인이 되므로 사용이 중단된 모든 함수는 경고에 따라 수정되어야 한다.


Tweaks

빡빡한 내부 루프에서 도움이 되는 몇가지 사소한 사항이다.

  • 불필요한 배열을 피하라. 예를 들어 sum([x, y, z]) 를 사용하지 말고 x+y+z 를 사용하라.
  • abs(z)^2 대신에 abs2(z) 를 사용하라. 일반적인 경우 복소수 인자에 abs 를 사용하지 말고 abs2를 이용하여 코드를 다시 써라.
  • 정수의 나누기의 몫을 구할때는 trunc(x/y) 대신에 div(x, y) 를 사용하고, floor(x/y) 대신에 fld(x, y) 를, ceil(x/y) 대신에 cld(x, y) 를 사용하라.

Performance Annotations

때로는 특정 프로그램 속성을 보장하여 더 나은 최적화를 가능하게 할 수 있다.

  • @inbounds 를 사용하면 표현식 내의 경계 확인을 제거한다. 이것을 사용하기 전헤 꼭 확인하라. 만약 이후의 구문에서 경계를 벗어나면, 프로그램이 멈추거나, silent corruption 이 발생할 수 있다.
  • @fastmath 는 실수에 대해 올바른, 그러나 IEEE 수에 대해서는 다른 부동소수 연산을 허용한다. 이것때문에 수치적 결과가 달라질 수 있으므로 조심해야 한다. 이것은 clang 의 ffast-math 옵션에 상응한다.
  • for 루프 앞의 @simd 매크로는 사용할 경우, 반복이 독립적이며, 재배열 될 것을 보장해야 한다(역자주 : 한 루프가 다른 루프에 영향을 주지 않으며, 각 반복의 순서에 무관해야 한다.) 많은 경우 Julia는 @simd 매크로 없이도 자동적으로 코드를 벡터화 한다; it is only beneficial in cases where such a transformation would otherwise be illegal, including cases like allowing floating-point re-associativity and ignoring dependent memory accesses (@simd ivdep). Again, be very careful when asserting @simd as erroneously annotating a loop with dependent iterations may result in unexpected results. In particular, note that setindex! on some AbstractArray subtypes is inherently dependent upon iteration order. This feature is experimental and could change or disappear in future versions of Julia.

1:n 을 사용하여 AbstractArray로 인덱싱하는 일반적인 관습은 Array가 비관습적인 인덱싱을 사용하는 경우 안전하지 않으며 경계 검사가 꺼져 있으면 세그멘테이션 오류가 발생할 수 있다. LinearIndices(x) 또는 eachindex(x)를 대신 사용하라(Arrays with custom indices 를 참고하라.)

주의
@simd 가 가장 내부의 for 루프에 직접적으로 위치해야 하는 반면에 @inbounds@fastmath 는 단일 표현이나 중첩된 코드 블락의 모든 표현식에서 사용될 수 있다. 즉 @inbounds begin 이나 @inbounds for 처럼.

Here is an example with both @inbounds and @simd markup (we here use @noinline to prevent the optimizer from trying to be too clever and defeat our benchmark):
다음은 @inbounds@simd 를 동시에 지정(markup) 하는 보기이다(여기서 @noinline 이 사용되었는데 이는 옵티마이저가 지나치게 똑똑해져서 우리의 벤치마크를 이기는 것을 방지하기 위해서이다(?))

@noinline function inner(x, y)
    s = zero(eltype(x))
    for i=eachindex(x)
        @inbounds s += x[i]*y[i]
    end
    return s
end

@noinline function innersimd(x, y)
    s = zero(eltype(x))
    @simd for i = eachindex(x)
        @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.4 GHz 인텔 코어 i5 프로세서에서 의 결과는 다음과 같다.

GFlop/sec        = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018

(GFlop/sec 는 성능 측정 단위이며 클수록 좋다)

Here is an example with all three kinds of markup. This program first calculates the finite difference of a one-dimensional array, and then evaluates the L2-norm of the result:
여기에 세가지 종류의 지정의 보기를 제시한다. 이 프로그램은 일차원 배열의 유한 차분을 계산하고 그 결과의 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 사용에만 최대한 의존하게 한다. 많은 실제 세상의 프로그램에서는 이련 경우가 거의 없다) 또한 이 경우, 최적화가 그 결과값을 바꾸지 않았다. 많은 경우 그 결과는 약간 차이가 난다. 어떤 경우, 특히 수치적으로 불안한(Round-off 에러에 의해 그 값이 상당히 변할 수 있는) 알고리즘의 경우는, 그 결과값이 매우 차이가 날 수 있다.

-- to be continued --


@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)
Variables
  #self#::Core.Const(f)
  x::Float64
  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 처럼 약간의 연습이 필요하다. 당신의 코드는 컴파일된 기계 코드를 생성하는 과정에서 많이 요약된 형태로 제시된다. 대부분의 표현은 ::T 로 타입이 제시되어 있다.(여기서 T 는 예를 들자면 Float64 일 수 있다) @code_warntype 의 가장 중요한 특징은 구체적이지 않은 타입은 빨간색으로 표현된다는 것이다; 이 문서는 마크다운으로 쓰여져 있는데, 마크다운은 색을 표현하는 기능이 없으므로, 빨간색 문자는 대문자로 표현하였다. (예를 들면 y::UNION{FLOAT64, INT64} 에서 :: 이하는 julia REPL 에서는 빨간색 ::Union{Float64, Int64} 으로 표현되지만 여기서는 대문자화하여 ::UNION{FLOAT64, INT64} 로 썼다.)

최상단에는 함수의 추정된 반환 타입이 Body::Float64로 표시된다. 다음줄은 Julia 의 SSA IR 형식으로 f 의 본체(Body)를 표현한다. 숫자가 적힌 상자들은 레이블 이며 당신의 코드에서 (goto 를 통해) 점프하는 지점을 의미한다. Body 를 보면 첫번째로 발생하는 것은 pos 가 호출되며 리턴값이 Union 타입 UNION{FLOAT64, INT64}으로 추론되는데 non-concrete 타입이므로 대문자로 보여진다. 이것은 입력 타입들로부터 pos 의 정확한 리턴 타입을 알 수 없다는 것을 의미한다. 그러나 yFloat64Int64 든 상관 없이 y*x 의 결과는 Float64 이다. 몇몇 중간 계산들이 타입-불안정 할 지라도 f(x::Float64) 의 출력은 타입-불안정 하지 않다.

이 정보를 어떻게 사용할지는 당신에게 달려 있다. 분명하게도 pos 를 타입-안정적이 되도록 고정하는것은 지극히 최선이 될 것이다: 당신이 그렇게 한다면 f 내의 모든 변수는 concrete 타입이 되고, 그 성능은 최적이 된다. 그러나 이런 종류의 일시적인 타입 불안정성이 크게 중요하지 않은 상황이 있다: 여를 들자면 pos 가 결코 고립되어(in isolation) 사용되지 않는다면, f 의 출력이 Float64인 입력 타입에 대해 타입-안정적이므로 이후 코드를 타입 불안정성이 전파(propagating) 되는 것으로부터 보호해준다. 이것은 특히 타입 물안정성을 수정하는 것이 어렵거나 불가능할 경우와 깊이 관계가 있다. 이 경우, 위에 기술한 팁들(예를 들자면, 타입 지정을 추가하거나 함수를 분리하는것 등)은 타입 불안정성으로부터의 피해를 방지하는 가장 좋은 수단이다. 예를 들어, 함수 firstfind 는 어떤 키가 발견되는 인덱스를 리턴하거나, 키가 없다면 nothing을 리턴하므로 명백히 타입-불안정 하다. 중요할 지도 모르는 타입-불안정성을 쉽게 찾기 위해 missing 이나 nothing 을 포함하는 Unions 는 빨간색이 아닌 노란색으로 강조된다.

아래의 예는 non-leaf types 를 포함한다고 표시된 표현들을 해석하는데 도움이 된다.

  • Body::UNION{T1, T2} 로 시작하는 함수 Body
    • 해석 : 함수의 반환 타입이 안정적이지 않다.
    • 제안 : 반환값의 타입을 지정해야 하더라도, 반환값을 타입-안정적으로 만들라.
  • 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
    • 해석 : non-leaf type 인 필드를 취하라. 이 경우 x 의 타입 ArrayContainerdata::Array{T} 필드를 가지고 있다. 그러나 Array 는 차원 N 을 필요로 하는데 N 은 concrete type 이어야 한다.
    • 제안 : Array{T, 3} 이나 Array{T, N} 과 같은 concrete 타입을 사용하라. 여기서 N 은 이제 ArrayContainer 의 매개변수이다.



1 역자의 iMac 에서 실행해보면,

julia> code_llvm(func, Tuple{MyType{Float64}})
;  @ REPL[7]:1 within `func`
define double @julia_func_399({}* nonnull align 8 dereferenceable(8) %0) #0 {
top:
; ┌ @ Base.jl:42 within `getproperty`
   %1 = bitcast {}* %0 to double*
   %2 = load double, double* %1, align 8
; └
; ┌ @ promotion.jl:379 within `+` @ float.jl:399
   %3 = fadd double %2, 1.000000e+00
; └
  ret double %3
}

julia> code_llvm(func, Tuple{MyType{AbstractFloat}})
;  @ REPL[7]:1 within `func`
define nonnull {}* @japi1_func_411({}* %0, {}** %1, i32 %2) #0 {
top:
  %3 = alloca [2 x {}*], align 8
  %gcframe2 = alloca [3 x {}*], align 16
  %gcframe2.sub = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 0
  %.sub = getelementptr inbounds [2 x {}*], [2 x {}*]* %3, i64 0, i64 0
  %4 = bitcast [3 x {}*]* %gcframe2 to i8*
  call void @llvm.memset.p0i8.i32(i8* nonnull align 16 dereferenceable(24) %4, i8 0, i32 24, i1 false)
  %5 = alloca {}**, align 8
  store volatile {}** %1, {}*** %5, align 8
  %6 = call {}*** inttoptr (i64 140703617757141 to {}*** (i64)*)(i64 261) #2
  %7 = bitcast [3 x {}*]* %gcframe2 to i64*
  store i64 4, i64* %7, align 16
  %8 = load {}**, {}*** %6, align 8
  %9 = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 1
  %10 = bitcast {}** %9 to {}***
  store {}** %8, {}*** %10, align 8
  %11 = bitcast {}*** %6 to {}***
  store {}** %gcframe2.sub, {}*** %11, align 8
  %12 = bitcast {}** %1 to {}***
  %13 = load {}**, {}*** %12, align 8
; ┌ @ Base.jl:42 within `getproperty`
   %14 = load atomic {}*, {}** %13 unordered, align 8
; └
  %15 = bitcast {}* %14 to i64*
  %16 = getelementptr inbounds i64, i64* %15, i64 -1
  %17 = load atomic i64, i64* %16 unordered, align 8
  %18 = and i64 %17, -16
  %19 = inttoptr i64 %18 to {}*
  %.not = icmp eq {}* %19, inttoptr (i64 4901751808 to {}*)
  br i1 %.not, label %L5, label %L8

L5:                                               ; preds = %top
  %20 = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 2
  store {}* %14, {}** %20, align 16
  %21 = call nonnull {}* @"j_+_412"({}* nonnull %14, i64 signext 1) #3
  br label %L10

L8:                                               ; preds = %top
  %22 = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 2
  store {}* %14, {}** %22, align 16
  store {}* %14, {}** %.sub, align 8
  %23 = getelementptr inbounds [2 x {}*], [2 x {}*]* %3, i64 0, i64 1
  store {}* inttoptr (i64 4453740640 to {}*), {}** %23, align 8
  %24 = call nonnull {}* @jl_apply_generic({}* inttoptr (i64 4917381120 to {}*), {}** nonnull %.sub, i32 2)
  br label %L10

L10:                                              ; preds = %L8, %L5
  %value_phi = phi {}* [ %21, %L5 ], [ %24, %L8 ]
  %25 = load {}*, {}** %9, align 8
  %26 = bitcast {}*** %6 to {}**
  store {}* %25, {}** %26, align 8
  ret {}* %value_phi
}

이다. Julia REPL 과 markdown 의 신텍스 하이라이팅 컬러가 다르므로 Julia REPL 에서는 다른 색이르 보일 수 있다.

2 예를 들어 배열 X=[1 2 3 4] 에 대한 sin.(cos.(x))[sin(cos(x)) for x in X] 와 결과가 같다.

3 @. exprexpr 의 모든 함수 호출을 도트 호출로 (예를 들면 f(x)f.(x) 로), 모든 할당을 도트 할당으로 (예를 들면 +=.+= 으로) 바꾸어 주는 매크로이다.

profile
Julia, Python, JS;Physics, Math, Image processing

0개의 댓글