본문 바로가기
Engineer/Juila

[Julia] 기본2 - 2. 코드 최적화하는 법

by _제이빈_ 2021. 10. 29.

링크의 내용을 공부하며 제멋대로 번역한 내용입니다.

 

 

 

줄리아 언어를 최적화 시키는 방법


TYPES AND TYPE STABILITY 타입과 타입안정성(?)

구조체를 만들때 타입을 정의를 명확히 하는 것이 속도가 빠르다. 구조체 요소의 타입을 정의하는 3가지 방법을 비교해 볼 것이며, 아래와 같다.

mutable struct Cube
	length
    width
    height
end    
volume(c::Cube) = c.length * c.width * c.height

mutable struct Cube_typed
	length::Float64
    width::Float64
    height::Float64
end    
volume(c::Cube_typed) = c.length * c.width * c.height
mutable struct Cube_parametric_typed{T <: Real}
	length::T
    width::T
    height::T
end
volume(c::Cube_parametric_typed) = c.length * c.width * c.height

c1 =                  Cube(1.1, 1.2, 1.3)
c2 =            Cube_typed(1.1, 1.2, 1.3)
c3 = Cube_parametric_typed(1.1, 1.2, 1.3)
@show volume(c1) == volume(c2) == volume(c3)

아에 명시하지 않는 방법, 그리고 직접 정의하는 법, 그리고 파라매트릭하게 명시하는 법이 있다. 이제 속도를 측정하기 위해 밴치마크툴을 통해 비교를 해보자!

using BenchmarkTools
@btime volume(c1) # not typed
@btime volume(c2) # typed float
@btime volume(c3) # typed parametric

## Output
>> 33.837 ns (1 allocation: 16 bytes)
>>  8.200 ns (1 allocation: 16 bytes)
>> 25.729 ns (1 allocation: 16 bytes)

이렇게 타입을 명확하게 정의한 두번째 구조체의 연산이 가장 빠르다. 이는 @code_warntype 으로 확인할 수 있다.

@code_warntype volume(c1)
Body::Any
│╻ getproperty1 1 ─ %1 = (Base.getfield)(c, :length)::Any
││  │   %2 = (Base.getfield)(c, :width)::Any
││  │   %3 = (Base.getfield)(c, :height)::Any
│   │   %4 = (%1 * %2 * %3)::Any
│   └──      return %4

@code_warntype volume(c2)
Body::Float64
│╻  getproperty6 1 ─ %1 = (Base.getfield)(c, :length)::Float64
││   │   %2 = (Base.getfield)(c, :width)::Float64
││   │   %3 = (Base.getfield)(c, :height)::Float64
││╻  *  │   %4 = (Base.mul_float)(%1, %2)::Float64
│││  │   %5 = (Base.mul_float)(%4, %3)::Float64
│    └──      return %5

이렇게 Any라는 타입이 선언되기 때문에 그렇다.

 

 

구조체 함수 뿐만 아니라, 함수를 정의함에 있어 내부에서 쓰이는 변수들의 타입도 맞춰져야 한다. 아래 예시를 보자.

function flipcoin_then_add(v::Vector{T}) where T <: Real
    s = 0
    for vi in v
        r = rand()
        if r >=0.5
            s += 1
        else
            s += vi
        end
    end
end

function flipcoin_then_add_typed(v::Vector{T}) where T <: Real
    s = zero(T)
    for vi in v
        r = rand()
        if r >=0.5
            s += one(T)
        else
            s += vi
        end
    end
end
myvec = rand(1000)
@show flipcoin_then_add(myvec) == flipcoin_then_add_typed(myvec)

>> true

함수 내부에서 계산되는 변수들의 타입을 모두 맞춰주고 있고 없고의 차이이다. 자 이제 속도를 확인해보자.

@btime flipcoin_then_add(myvec)
@btime flipcoin_then_add_typed(myvec)
>> 3.075 μs (0 allocations: 0 bytes)
>> 3.312 μs (0 allocations: 0 bytes)

아니 이 포스팅 이상하다. 아니면 버전이 올라가면서 바뀐건지........굳이 0을 0.0 혹은 zero(T) 따위로 하는 것 보다 그냥 0으로 주는게 더 좋다..... 아니면 파라매트릭이라 그런가?

function flipcoin_then_add_typed2(v::Vector{Float64})
    s = zero(Float64)
    for vi in v
        r = rand()
        if r >=0.5
            s += one(Float64)
        else
            s += vi
        end
    end
end

아니 걍 이상한 것이다.... 뭐 파라매트릭보다야 낫지만 말이다...

@btime flipcoin_then_add(myvec)
@btime flipcoin_then_add_typed(myvec)
@btime flipcoin_then_add_typed2(myvec)
>> 3.263 μs (0 allocations: 0 bytes)
>> 3.312 μs (0 allocations: 0 bytes)
>> 3.288 μs (0 allocations: 0 bytes)

좀 뜯어봐야겠다. 나중에...

 


MEMORY 메모리

미리 메모리를 할당해 놓는 것이 빠르다. PUSH!를 쓰는 것과 미리 할당해 놓는 차이를 확인해 보자.

function build_fibonacci_preallocate(n::Int)
    @assert n >= 2
    v = zeros(Int64,n)
    v[1] = 1
    v[2] = 1
    for i = 3:n
        v[i] = v[i-1] + v[i-2]
    end
    return v
end

function build_fibonacci_no_allocation(n::Int)
    @assert n >= 2
    v = Vector{Int64}()
    push!(v,1)
    push!(v,1)
    for i = 3:n
        push!(v,v[i-1]+v[i-2])
    end
    return v
end
n = 100
@btime build_fibonacci_no_allocation(n);
@btime build_fibonacci_preallocate(n);
>> 905.263 ns (7 allocations: 2.20 KiB)
>> 157.875 ns (1 allocation: 896 bytes)

차이가 아주 크다!

 

 

그리고 Matrix의 원소들에 차례대로 접근한다면, 열을 먼저 접근해야한다. 아래 예시를 보자.

m = 10000
n = 10000
A = rand(m,n)

function matrix_sum_rows(A::Matrix)
    m,n = size(A)
    mysum = 0
    for i = 1:m # fix a row
        for j = 1:n # loop over cols
            mysum += A[i,j]
        end
    end
    return mysum
end

function matrix_sum_cols(A::Matrix)
    m,n = size(A)
    mysum = 0
    for j = 1:n # fix a column
        for i = 1:m # loop over rows
            mysum += A[i,j]
        end
    end
    return mysum
end

function matrix_sum_index(A::Matrix)
    m,n = size(A)
    mysum = 0
    for i = 1:m*n
        mysum += A[i]
    end
    return mysum
end

열을 먼저 접근하는 것이 훠어어얼씬 빠르다!

@btime matrix_sum_rows(A)
@btime matrix_sum_cols(A)
@btime matrix_sum_index(A)
>> 796.757 ms (1 allocation: 16 bytes)
>> 87.954 ms (1 allocation: 16 bytes)
>> 273.888 ms (1 allocation: 16 bytes)

 

 

그리고 메모리를 재사용하는 것이 좋다! 아래 예시는 벡터계산이랑 메모리 재사용하는 계산이랑 비교한 것이다.

b = rand(1000)*10
h = rand(1000)*10

function find_hypotenuse(b::Vector{T},h::Vector{T}) where T <: Real
    return sqrt.(b.^2+h.^2)
end


function find_hypotenuse_optimized(b::Vector{T},h::Vector{T}) where T <: Real
    accum_vec = similar(b)
    for i = 1:length(b)
        accum_vec[i] = b[i]^2
        accum_vec[i] = accum_vec[i] + h[i]^2 # here, we used the same space in memory to hold the sum
        accum_vec[i] = sqrt(accum_vec[i]) # same thing here, to hold the sqrt
        # or:
        # accum_vec[i] = sqrt(b[i]^2+h[i]^2)
    end
    return accum_vec
end

시간을 재보자!

# Let's time it
@btime find_hypotenuse(b,h);
@btime find_hypotenuse_optimized(b,h);
>> 4.057 μs (4 allocations: 31.75 KiB)
>> 2.278 μs (1 allocation: 7.94 KiB)

그렇다. 오히려 백터 계산보다 변수를 정의하고 재사용하는게 효율적이다. 벡터계산을 하기 위해 할당하는 메모리로 인해 속도가 느려지는 경우이다. 만약, 업데이트가 아닌 인풋을 읽고 사용만 할거면 함수 내에서 로컬 변수를 작성하는게 좋다!

첫번째 함수는 b.^2을 홀드해놨다가, h.^2도 홀드하고 둘합친것도 홀드하고 sqrt를한다. 대신 두번째 함수는 각 계산들을 계속 한 메모리에 재할당하므로 최적화 시킨 것이다.

반응형

댓글