logo

줄리아 플럭스에서 GPU 사용하는 법 📂머신러닝

줄리아 플럭스에서 GPU 사용하는 법

개요

줄리아머신러닝 라이브러리인 Flux.jl1 통해 딥러닝을 구현하되, GPU를 통해 성능을 학습을 가속시키는 방법에 대해 소개한다.

GPU를 사용하기 위해서는 CUDA.jl2 사용해야하며, 사전에 쿠다에 관련된 세팅이 되어있어야 한다. 쿠다의 세팅 자체는 파이썬에서와 같으므로, 다음의 포스트를 참고하자:

분명히 말하지만 GPU를 통한 딥러닝은 기본인 동시에 초심자에게 큰 장벽이기도 하다. 그런 측면에서는 줄리아에서도 공식 튜토리얼도 다소 불친절한 감이 있지만, 알고보면 아주 직관적이고 간단하게 GPU 가속이 가능하다.

코드

julia> using Flux, BenchmarkTools
       optimizer = Adam()
       
       f(v) = v[1].^2 + 2v[2] - v[3]*v[4]
       n = 10_000
       
       function ftry_FFNN()
           FFNN = Chain(
               Dense( 4, 100, relu),
               Dense(100, 100, relu),
               Dense(100, 100, relu),
               Dense(100, 1),
           )
           return FFNN
       end
ftry_FFNN (generic function with 1 method)

이 포스트에서는 간단히 순방향 신경망feed-Forward Neural Network, FFNNMLP를 구현해서 비선형함수를 찾는 문제를 예제로 삼는다. 실제로 이 인공신경망의 성능이 어떠한지가 당장 중요한 것은 아니고, 신경망의 학습에 GPU를 어떻게 사용하는지에 초점을 맞추자. 함수 ftry_FFNN()의 명명은 FFNN을 리턴하는 공장factory라는 의미로 정해진 것이다.

julia> X = rand(Float32, 4, n)
4×10000 Matrix{Float32}:
 0.669836  0.260559  0.710337  0.5121    …  0.298401  0.763405  0.977941   0.89907
 0.135182  0.938298  0.110935  0.534417     0.804302  0.197353  0.784419   0.179469
 0.287801  0.839834  0.718759  0.15976      0.442106  0.696369  0.41352    0.617924
 0.213884  0.345568  0.426718  0.961369     0.542308  0.378965  0.0633196  0.580489

julia> Y = f.(eachcol(X))'
1×10000 adjoint(::Vector{Float32}) with eltype Float32:
 0.657488  1.65427  0.419741  1.17749  …  1.45789  0.713595  2.49902  0.808568

데이터는 그냥 랜덤하게 생성한다.

CPU

julia> data = Flux.DataLoader((X, Y), shuffle = true, batchsize = 1000);

julia> cFFNN = ftry_FFNN()
Chain(
  Dense(4 => 100, relu),                # 500 parameters
  Dense(100 => 100, relu),              # 10_100 parameters
  Dense(100 => 100, relu),              # 10_100 parameters
  Dense(100 => 1),                      # 101 parameters
)                   # Total: 8 arrays, 20_801 parameters, 81.754 KiB.

julia> Loss(x,y) = Flux.mse(cFFNN(x), y)
Loss (generic function with 1 method)

julia> ps = Flux.params(cFFNN);

julia> @btime Flux.train!(Loss, ps, data, optimizer)
  22.027 ms (3413 allocations: 60.07 MiB)

cpu.webp

에포크가 반복되면서 CPU의 점유율이 올라가는 것을 확인할 수 있다.

GPU

julia> using CUDA

julia> CUDA.functional()
true

우선 CUDA.jl을 로드한 후 CUDA.functional()을 통해 쿠다가 잘 작동할 수 있는 환경인지 확인하는 작업이 필요하다. 결과로써 true가 출력되면 쿠다가 작동한다는 의미다. 그 다음은 딱 두가지만 하면 된다.

첫째, 데이터를 GPU에 올린다:

julia> X = X |> gpu
4×10000 CuArray{Float32, 2, CUDA.Mem.DeviceBuffer}:
 0.490089  0.305455  0.334825  0.0466784  …  0.243163  0.732825    0.401764  0.361136      
 0.102807  0.755049  0.971202  0.522919      0.766326  0.498049    0.669154  0.546359      
 0.440698  0.777547  0.263636  0.448606      0.854045  0.465477    0.314271  0.854085      
 0.288417  0.932674  0.808397  0.180423      0.63759   0.00347775  0.565556  0.872233      

julia> Y = Y |> gpu
1×10000 adjoint(::CuArray{Float32, 1, CUDA.Mem.DeviceBuffer}) with eltype Float32:
 0.318696  0.878203  1.84139  0.967079  0.162922  …  1.04725  1.53151  1.32198  0.478175   

julia> data = Flux.DataLoader((X, Y), shuffle = true, batchsize = 1000);

둘째, 함수를 GPU에 올린다:

julia> gFFNN = ftry_FFNN() |> gpu
Chain(
  Dense(4 => 100, relu),                # 500 parameters
  Dense(100 => 100, relu),              # 10_100 parameters
  Dense(100 => 100, relu),              # 10_100 parameters
  Dense(100 => 1),                      # 101 parameters
)                   # Total: 8 arrays, 20_801 parameters, 1.109 KiB.

그리고 CPU에서 하던 것과 마찬가지로 평범하게 학습을 진행하면 된다:

julia> Loss(x,y) = Flux.mse(gFFNN(x), y)
Loss (generic function with 1 method)

julia> ps = Flux.params(gFFNN);

julia> @btime Flux.train!(Loss, ps, data, optimizer)
  8.588 ms (24313 allocations: 1.90 MiB)

요약하자면 다음과 같다, 딱 이 세 줄이면 GPU로 딥러닝이 가능하다:

  • X |> gpu
  • Y |> gpu
  • FFNN |> gpu

gpu.webp

초보자가 GPU를 사용할 때 가장 중요한 것은 항상 하드웨어의 자원 소모량을 모니터링하면서 실제로 디바이스를 사용하고 있는가, 오히려 CPU보다 느려지지는 않았는가 하는 부분이다. 쿠다에 관련된 세팅을 잘못하거나 GPU 프로그래밍에 익숙하지 않은 경우 CPU만 돌고 있는데 ‘돌아가니까 문제 없나보다’하고 원효대사 해골물을 마실 수도 있다.

성능비교

julia> @btime Flux.train!(Loss, ps, data, optimizer) # CPU
  23.120 ms (3413 allocations: 60.07 MiB)

julia> @btime Flux.train!(Loss, ps, data, optimizer) # GPU
  8.427 ms (24313 allocations: 1.90 MiB)

GPU의 경우 CPU보다 약 세 배정도 빨라진 걸 볼 수 있는데, 사실 여기서 확인할 수 있는건 ‘GPU를 써서 손해를 보진 않았다’는 정도고 실제 환경에서는 이보다 큰 차이가 나는 편이다. 특히 데이터의 크기와 아키텍쳐가 커질수록 그러한데, 필자가 연구에서 사용하던 신경망은 약 50배 이상의 성능차이를 경험했다.

반면 신경망에 복잡한 함수가 사용되거나 인덱싱이 필요한 경우와 같이 ‘성능’이 우선이 아니라 ‘구현’ 자체에 의미가 있는 연구에서는 오히려 GPU를 사용해서 손해를 볼 가능성이 있다. 이 경우엔 그냥 CPU로 선회하거나 GPU에 적합하도록 신경망을 구성할 때 온갖 트릭을 사용할 수도 있는데, GPU에서 쾌적하게 돌아가게끔 레이어를 구성하는 것은 개개인의 역량에 크게 좌우된다.

전체코드

using Flux

optimizer = Adam()

f(v) = v[1].^2 + 2v[2] - v[3]*v[4]
n = 10_000

function ftry_FFNN()
    FFNN = Chain(
        Dense( 4, 100, relu),
        Dense(100, 100, relu),
        Dense(100, 100, relu),
        Dense(100, 1),
    )
    return FFNN
end

X = rand(Float32, 4, n)
Y = f.(eachcol(X))'

data = Flux.DataLoader((X, Y), shuffle = true, batchsize = 1000);

cFFNN = ftry_FFNN()

Loss(x,y) = Flux.mse(cFFNN(x), y)
ps = Flux.params(cFFNN);
@btime Flux.train!(Loss, ps, data, optimizer)

# --------------------

using CUDA

CUDA.functional()
X = X |> gpu
Y = Y |> gpu
data = Flux.DataLoader((X, Y), shuffle = true, batchsize = 1000);
gFFNN = ftry_FFNN() |> gpu

Loss(x,y) = Flux.mse(gFFNN(x), y)
ps = Flux.params(gFFNN);
@btime Flux.train!(Loss, ps, data, optimizer)

using BenchmarkTools

@btime Flux.train!(Loss, ps, data, optimizer) # CPU
@btime Flux.train!(Loss, ps, data, optimizer) # GPU

환경

  • OS: Windows
  • julia: v1.9.0
  • Flux v0.13.16