ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • CuPy란?
    Software Development/GPU Programming 2020. 4. 6. 12:39

    CuPy는 오픈소스 라이브러리로 NumPy 문법을 사용하며 NVIDIA GPU를 사용하여 행렬 연산으로 속도를 향상시킵니다. 

     

    1. 소개

    NumPy는 머신러닝이나 알고리즘을 개발하는 사람들에게 표준화된 툴입니다. NumPy는 다차원 배열을 제공하며 과학적 계산을 위한 근본적인 자료구조 그리고 다양한 기능과 함수를 제공합니다. NumPy를 기반으로 scikit-learn과 같은 라이브러리들이 개발되었습니다. 딥러닝의 인기로 최근 5년 동안 GPU를 이용한 병렬 컴퓨팅이 머신러닝 연구자들에 의해 증가되고 있습니다. 신경망의 수가 증가할 수록 학습과 적용을 위한 더 많은 계산량이 필요합니다. 딥러닝 계산은 주로 선형대수 계산을 필요하고 이 계산은 NumPy의 강점입니다. 그러나 NumPy는 GPU위에서의 계산을 지원하지 않습니다. 이 계기로 GPU를 이용한 더 빠른 계산을 위하여 NumPy와 호환이 가능한 CuPy가 개발되었습니다. 

    CuPy는 처음에 Python-based deep learning framework인 Chainer의 백엔드로 개발되었습니다. Chainer의 초기 버전은 CUDA GPU 계산을 위해 널리 사용되는 Python library인 PyCUDA에 의해 실행되었습니다. 그러나 PyCUDA의 아쉬운 부분은 NumPy랑 문법이 다르다는 것입니다. PyCUDA는 CUDA 개발자들을 위해 디자인 되었지 NumPy에 기초한 GPU 실행을 원하는 머신러닝 개발자들을 위해 디자인 된 것이 아닙니다. 그러므로 사용자들이 GPU 계산을 CUDA 문법없이 NumPy와 동등한 라이브러리로 사용할 수 있도록 PyCUDA에서 CuPy가 디자인 되었습니다.

     

    CuPy는 다음과 같은 이점이 있습니다.

     

    고성능의 NVIDIA GPUs: CuPy는 NVIDIA의 CUDA를 사용하고 GPU 아키텍처의의 완전한 사용을 위해 CUDA와 관련된 다른 라이브러리인 cuBLAS, cuDNN, cuRAND, cuSOLVER, cuSPARSE, NCCL를 사용합니다.  

    NumPy와의 높은 호환성: CuPy의 인터페이스는 NumPy와 호환성이 높습니다. Python 코드에서 NumPy를 CuPy로 바꾸는 것 만으로도 GPU 계산으로 변경하는데 충분합니다.

    쉬운 설치 방법: CuPy는 pip를 통해 설치되며 다양한 버전의 CUDA와 cuDNN을 지원합니다. 설치시에 자동으로 설치된 CUDA와 cuDNN 버전을 감지하며 그것과 일치하게 CuPy를 설정합니다.

    custom kernel를 쉽게 작성: 사용자는 코드를 빠르게 실행시키기 위해 C++ 코드 snippets 사용해서 CUDA kernel을 쉽게 만들 수 있습니다. CuPy는 자동적으로 감싸고 CUDA binary로 코드를 컴파일합니다. 컴파일된 바이너리는 캐싱되어 후속 실행에서 재사용 됩니다. 

    CuPy는 Chainer로 부터 2017년 6월에 Chainer 버전 2와 CuPy 버전1이 배포되었고 독립되었습니다. 그 이후로 CuPy의 채택은 Chainer 커뮤니티 밖에서 퍼지고 있습니다. 예를 들어 Python-based Probabilistic modeling software인 Pomegranate는 CuPy를 GPU Backend로 사용합니다. 이 모든 것은 NumPy와 비슷한 디자인을 가지고 NVIDIA 라이브러리를 바탕으로 강한 성능 덕분이라 생각합니다.


    2. CuPy의 기초
    다차원 배열: CuPy는 NumPy와 같은 Python 패키지기에 똑같은 방법으로 import할 수 있습니다. 아래의 코드에서 cp는 CuPy의 약어입니다.

    import numpy as np
    import cupy as cp

    cuda.ndarray 클래스는 numpy.ndarray의 GPU 대안으로 CuPy의 핵심입니다. 여기서 ndarray의 nd는 n-dimensional을 의미합니다. 

    x_gpu = cp.array([1, 2, 3])

    위 예에서 x_gpu는 cuda.array의 인스턴스입니다. numpy를 cupy로 바꾸는 것 빼고는 numpy와 문법이 동일합니다. numpy.array에서 cupy.ndarray의 주된 차이는 GPU 메모리에 할당된다는 것입니다.

    대부분의 CuPy array 조작은  NumPy와 같습니다. Euclidean norm(a.k.a L2 norm)을 예로 들면 NumPy는 CPU에서 numpy.linalg.norm을 계산해서 사용합니다. 

    x_cpu = np.array([1, 2, 3])
    l2_cpu = np.linalg.norm(x_cpu)

    CuPy로 GPU에서 다음과 같이 계산할 수 있습니다.  

    x_gpu = cp.array([1, 2, 3])
    l2_gpu = cp.linalg.norm(x_gpu)

    CuPy는 cupy.ndarray 객체에 많은 함수를 실행합니다. NumPy의 문서를 보는 것이 CuPy 사용에 도움이 될 수 있습니다.

    import cupy as cp
    x = cp.arange(6).reshape(2, 3).astype(’f’)
    x.sum(axis=1)

    현재 장치: CuPy는 항당 조작 계산 등이 있는 기본 장치인 현재 장치 설정을 가지고 있습니다. 현재 장치는 cupy.cuda.Device.ues()에 의해 다음과 같이 설정할 수 있습니다. 

    cp.cuda.Device(0).use()
    x_on_gpu0 = cp.array([1, 2, 3, 4, 5])
    cp.cuda.Device(1).use()
    x_on_gpu1 = cp.array([1, 2, 3, 4, 5])

    현재 GPU를 일시적으로 바꾸려면 with문이 유용합니다.
    with cp.cuda.Device(1):
    x_on_gpu1 = cp.array([1, 2, 3, 4, 5])
    x_on_gpu0 = cp.array([1, 2, 3, 4, 5])

    CPU와 GPU 간의 배열 이동: cupy.asarray()는 numpy.ndarray, list 또는 numpy.array()로 전달 될 수 있는 모든 객체를 현재 장치로 이동시키는데 사용할 수 있습니다.
    x_cpu = np.array([1, 2, 3])
    x_gpu = cp.asarray(x_cpu) # move the data to the current device.

    cupy.asarray()는 cupy.ndarray를 받을 수 있는데 array를 이 함수로 장치간에 이동을 할 수 있다는 것을 의미합니다.
    with cp.cuda.Device(0):
    x_gpu_0 = cp.ndarray([1, 2, 3]) # create an array in GPU 0
    with cp.cuda.Device(1):
    x_gpu_1 = cp.asarray(x_gpu_0) # move the array to GPU 1

    cupy.asarray()는 가능하면 입력 array를 복사하지 않습니다. 대신 입력 array 자체를 반환합니다. CuPy가 array를 복사하도록 하려면 cupy.array()를  cupy=True와 같이 사용해야 합니다.

    장치의 array를 host로 옮기는 것은 cupy.asnumpy()로 가능합니다.
    x_gpu = cp.array([1, 2, 3]) # create an array in the current device
    x_cpu = cp.asnumpy(x_gpu) # move the array to the host.

    cupy.ndarray.get()도 사용할 수 있습니다.
    x_cpu = x_gpu.get()

    CPU/GPU에 상관없는 코드를 쓰는 방법: CuPy/NumPy 호환성은 CPU/GPU 일반 코드를 허용합니다. 이것은 cupy.get_array_module() 함수를 사용해서 만들 수 있습니다. 이 함수는 인자가 cupy.ndarray인지 또는 numpy.ndarray인지에 따라 적절한 NumPy 또는 CuPy 모듈을 반환합니다. CPU/GPU generic 함수는 아래와 같이 정의할 수 있습니다.
    # CPU/GPU agnostic implementation of log(1 + exp(x))
    def softplus(x):
    xp = cp.get_array_module(x)
    return xp.maximum(0, x) + xp.log1p(xp.exp(-abs(x)))


    3 지원되는 기능들
    3.1 Indexing
    CuPy array는 다양한 타입의 유용한 indexing을 NumPy indexing과 동일한 문법으로 지원합니다. 
    x = cp.arange(10)
    print(x[3]) # int
    3

    print(x[2:7:2]) # slice
    5 [2 4 6]

    print(x[cp.array([0, 3, 5])]) # int array
    7 [0 3 5]

    print(x[x % 3 == 1]) # boolean array
    9 [1 4 7]


    3.2 선형 대수 
    CuPy는 NVIDIA의 cuBLAS를 사용해 NumPy에 있는 대부분의 선형 대수 함수를 지원합니다. cuBLAS는 LAPACK 구현의 CUDA 버전이며 eigen decomposition, Cholesky decomposition, QR decomposition, singular value decomposition, linear equation solver, inverse of matrix and Moore-Penrose pseudo inverse와 같은 많은 선형 대수 연산을 가지고 있습니다. 이러한 함수들은 cupy.linalg 에 정의되어 있으며 numpy.linalg와 호환됩니다.

    3.3 Sorting
    다른 작업들과는 달리, 요소별로 또는 축소법을 통해서 sort가 효율적으로 실행되지 않습니다. 그래서 CuPy는 C++ 의 병렬 알고리즘인 Thrust를 사용하여 성능을 향상시켰습니다. 이러한 구현 기법으로 cupy.sort 그리고 다른 sort function을 내부 매커니즘에 대한 고민없이 사용할 수 있습니다. CuPy는 현재 sort, argsort 그리고 lexsort를 지원합니다.

    3.4 Sparse Matrices(희소 행렬)
    CuPy는 NVIDIA의 cuSPARSE를 사용해 희소 행렬 지원합니다. 이 행렬은 SciPy의 scipy.sparse와 같은 인터페이스를 가지고 있습니다. 요구 사항에 따라 사용자는 좌표 형식의 희소 행렬, 압축 희소 행 행렬, 압축 희소 열 행렬, 또는 대각선 저장소가 있는 희소 행렬중 하나를 선택할 수 있습니다. 


    4 사용자 정의 CUDA kernels
    추가적인 속도 향상을 위해, 사용자는 그들의 프로그램에 최적화된 커널을 정의할 수 있습니다. CuPy는 커널에서 흔히 사용되는 두가지 타입을 지원합니다. 하나는 모든 데이터에 대해 같은 연산을 적용하는  element-wise kernel입니다. 예를 들어 더하기 함수는 각 데이터 쌍에 대해 + 연산을 적용합니다. 다른 하나는  이진 연산자로 모든 요소를 접는 reduction kernel입니다. 예를 들어 더하기 함수는 + 연산을 적용하여 모든 데이터를 접습니다. 

    사용자는 CUDA 코드 일부로 arbitrary element-wise kernels 그리고  reduction kernels을 정의할 수 있습니다. 아래는 사용자 정의 element-wise kernel의 예입니다.
    kernel = cupy.ElementwiseKernel(
    ’float32␣x,␣float32␣y,␣float32␣z’,  # input type
    ’float32␣w’,  # output type
    ’w␣=␣(x␣*␣y)␣+␣z;’,  # This is CUDA code snippet
    ’my_kernel’)  # kernel name
    w = kernel(x, y, z)

    첫 번째 인자는 입력 변수 리스트이고 두 번째 인자는 출력 변수의 리스트입니다. 각 벽수의 정의는 특정 타입과 인수의 이름으로 구성됩니다. 세 번째 인자는 사용자가 정의하고자 하는 코드 스니펫입니다. CUDA의 코드 스니펫이며 임의의 CUDA 코드를 사용할 수 잇습니다. 

    CuPy는 일반적인 유형도 지원합니다. 사용자가 float32대신 T와 같은 유형의 매개변수를 사용할 때 ElementwiseKernel은 탬플릿 기능을 생성합니다. 예를 들어 T x, T y와 같은 입력 타입일 때 이 함수는 정수와 float와 같은 표준 유형을 지원하게 됩니다.

    Element-wise kernels and reduction kernels은 MapReduce framework의 Map Reduce와 유사합니다. 

     


    5 NumPy와의 성능 비교
    GPU 처리 속도 덕분에 CuPy는 NumPy보다 여러면에서 빠릅니다.
    NumPy와 비교한 CuPy의 성능을 벤치마킹하기 위해 다음과 같은 게싼 환경을 벤치마킹하였습니다.

    • CPU: Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz x 2
    • Memory: 64GB
    • GPU: GeForce GTX TITAN X (Maxwell) x 4

    벤치마킹 코드는 다음과 같습니다.
    import time, numpy, cupy

    size = 10 ** 8
    def test(xp):
      return xp.arange(size).reshape(1000, -1).T * 2

    for xp in [numpy, cupy]:
      test(xp) # Avoid first call overhead
      Synchronize CPU and GPU for benchmark
      cupy.cuda.runtime.deviceSynchronize()
      t1 = time.time()
      test(xp)
      cupy.cuda.runtime.deviceSynchronize()
      t2 = time.time()
      print(xp.__name__, t2 - t1)

    표 1은 입력 행렬의 크기를 바꿔가면서 NumPy와 CuPy의 계산 시간을 보여줍니다. 작은 행렬에선 CuPy는 CUDA 커널을 실행해야하는 오버헤드 때문에 NumPy보다 느립니다. 큰 행렬에선 오버헤드가 실질적인 GPU 계산에 비교하여 작기 때문에 CuPy는 CPU-based NumPy보다 6배 빠릅니다.

    기계 학습 실무자는 종종 합리적인 시간 내에 큰 행렬에 알고리즘을 적용하는 방법에 대한 문제에 직면합니다. 여기가 NumPy에 대한 CuPy의 이점이 있는 부분입니다.


    6 지속적인 개발
    CuPy 개발팀은 초기 출시 이후 NumPy 호환 기능을 추가할 뿐만 아니라 memory pooling 및 kernel fusion이 도입되었습니다.

    6.1 Memory Pooling
    CUDA 프로그래밍에서 가능한한 cudaMalloc을 피하는 것이 일반적입니다. 메모리 할당 및 처리가 느리고 CPU와의 동기화가 필요하기 때문입니다. CuPy는 다양한 크기의 빈 (512, 1024, 1536, ...)을 관리하기 위해 가장 적합한 알고리즘을 기반으로 v1.0부터 메모리 풀링을 지원합니다. 그러나 최대 크기보다 큰 메모리 블록이 있는 경우 캐시 미스가 발생할 수 있습니다. 이 경우, 우리는 BFC (Coalesescing) 알고리즘에 가장 적합한 알고리즘을 구현하여 더 큰 빈을 필요한 부분으로 나눌 수 있으며 나머지 부분은 메모리 풀로 돌아갑니다. 이 메커니즘은 입력 크기가 다른 자연 언어 처리 응용 프로그램에 효과적입니다. Chainer v3.0 & CuPy v2.0에서 seq2seq 모델을 이용한 실험에서 메모리 사용량은 25% 이하로 감소하였습니다.

    6.2 GPU Memory Profiler
    프로파일러는 코드의 효율적인 디버깅 및 최적화를 위한 핵심 요소입니다. GPU 메모리 부족으로 "cudaErrorMemoryAllocation: out of memory" 에러는 흔히 볼 수 있습니다. NVIDIA는 nvprof와 nvvp를 제공하지만 GPU 내에서 무슨 일이 일어나고 있는지 알기엔 여전히 여러움이 있습니다. 그러므로 CuPy를 위한 GPU 메모리 프로파일링을 개발했습니다(MemoryHook and LineProfileHook). MemoryHook은 uPy(UsedBytes)의 메모리 풀과 CuPy 함수라고 불리는 각 기능에 대한 GPU 장치(AcquiredBytes)의 바이트 수를 측정할 수 있게 해줍니다. 이를 기반으로 Chainer v3.0은 기능별 메모리 프로파일러(CupyMemoryProfileHook)를 지원합니다. LineProfileHook은 또한 CuPy의 메모리 풀에서 사용된 바이트 수와 스택 트레이스의 각 라인에서 GPU 장치에서 사용된 바이트 수를 알려줍니다. 


    6.3 Kernel Fusion
    GPU는 각 커널을 호출하는 오버헤드 때문에 소규모 작업에서는 효율적이지 못할 수 있습니다. 소규모 작업을 합쳐 큰커널을 만드는 것이 더 좋습니다. 사용자 지정 최적화된 커널을 만들기 보다는 사용자는 Python 코드에서 소규모 작업을 결합 시킬 수 있습니다. 이를 CuPy의 kernel-fusion이라고 합니다. 

    kernel fusion을 적용하가 위해 사용자는 @cp.fuse()를 합치길 원하는 Python 함수위에 추가하면 됩니다. @cp.fuse() 함수의 경우 CuPy는 실제로 이들을 실행하고 해당 작업을 실행하는 단일 커널을 컴파일하고 호출합니다. 생성된 커널이 브로드캐스팅을 올바르게 처리합니다.
    @cp.fuse()
    def fused_func(x, y, z):
      return (x * y) + z

    fused_func(cp.arange(10), cp.arange(10), cup.arange(10))

     


    7. 결론
    CuPy는 GPU 계산 속도에서 NumPy 코드를 실행합니다. 비록 딥러닝 프레임워크 Chainer의 배열 백엔드로 개발되었지만 범용의 목적으로 사용될 수 있다. CuPy는 오픈소스이고 여전히 성장중이다. 이미 주요 기능이 지원되고 NumPy와 호환도 되지만 기여는 언제든지 환영한다. 머신러닝에서 NumPy를 쓰는 많은 사용자들이 CuPy를 써서 그들의 연구를 가속화 하기를 바란다. 

    Acknowledgments
    The authors would like to thank all of the developers of CuPy, especially Naotoshi Seo and MasayukiTakagi for their significant contributions to improve CuPy.

     

    출처: http://learningsys.org/nips17/assets/papers/paper_16.pdf

     

    댓글

Designed by Tistory.