본문 바로가기

Computer/Python

행렬과 벡터 연산 (4) - numpy 배열을 이용한 확산 방정식

반응형

행렬과 벡터 연산 (1) - 확산 방정식 예제

행렬과 벡터 연산 (2) - 확산 방정식 순수 파이썬 구현

행렬과 벡터 연산 (3) - 파이썬 리스트와 numpy 연산 속도 차이


 

지난 포스트에서 numpy 배열을 이용하면 메모리의 지역성과 CPU 벡터화 기능의 장점으로 행렬/벡터 연산에서 굉장한 성능 향상을 얻을 수 있음을 보았습니다. 그렇다면 지난 번에 파이썬 리스트로 구현했던 확산 방정식을 numpy 배열을 이용해 구현해 보겠습니다.

 

행렬과 벡터 연산 (2) - 확산 방정식 순수 파이썬 구현

[개발 잡학/Python] - 행렬과 벡터 연산 (1) - 확산 방정식 예제 지난 포스트의 의사 코드를 기반으로 순수 파이썬으로 (특히 리스트) 구현해 보겠습니다. 먼저 행렬을 받아 변화된 상태를 반환하는 e

hongl.tistory.com


확산 방정식에서 리스트를 사용한 연산이 집중되는 부분은 evolve 함수, grid 격자 전체를 순회하면서 현재 grid 인덱스 값을 상하좌우 값을 이용하여 업데이트하는 구간이죠. 이 부분은 현재 index의 상하좌우 값이 필요하고 grid 전체에 대해 수행해야하므로 numpy 라이브러리의 roll 함수로 대체할 수 있습니다. roll 함수는 numpy 배열, shift, axis (축) 을 인자로 받아 축을 기준으로 배열을 shift 값만큼 평행 이동한 새로운 배열을 생성합니다. 비록 배열을 위한 새로운 공간을 할당하고 데이터를 채워넣는 시간이 필요하지만, 배열을 한번 생성하고 나면 벡터 연산을 빠르게 할 수 있고 CPU 캐시 미스를 줄일 수 있습니다.

 

numpy.roll — NumPy v1.23 Manual

The number of places by which elements are shifted. If a tuple, then axis must be a tuple of the same size, and each of the given axes is shifted by the corresponding number. If an int while axis is a tuple of ints, then the same value is used for all give

numpy.org

roll 함수와 초기 grid 격자를 numpy zero 배열을 이용하여 구현하면 다음과 같습니다.

import numpy as np

grid_shape = (640, 640)

def laplacian(grid):
    return (
        np.roll(grid, 1, 0) +
        np.roll(grid, -1, 0) +
        np.roll(grid, +1, 1) +
        np.roll(grid, -1, -1) -
        4*grid
    )

def evolve(grid, dt, D=1):
    return grid + dt * D * laplacian(grid)

def run_experiment(num_iterations):
    xmax, ymax = grid_shape
    grid = np.zeros(grid_shape)

    ## 초기 조건
    block_low = int(grid_shape[0]*0.4)
    block_high = int(grid_shape[0]*0.5)
    for i in range(block_low, block_high):
        for j in range(block_low, block_high):
            grid[i][j] = 0.005

    for i in range(num_iterations):
        grid = evolve(grid, dt=0.1)

 

Discussion

무엇보다 속도가 굉장히 빨라졌습니다. 파이썬 리스트로 구현했을 때, 10분 이상 걸리던 작업이 5초 이내에 끝납니다. 빨라진 첫 번째 이유로는 numpy의 벡터화 연산 덕분에 CPU 명령의 효율이 매우 좋아졌기 때문입니다. 예를 들어 곱셈을 4번 하려고 명령을 4번 수행하는 대신 벡터화 연산 한 번으로 배열 내의 수 4개의 곱셈을 한 번에 수행할 수 있으므로 실행한 명령 수가 줄더라도 명령 당 하는 일이 더 많아졌기 때문이죠. 물론 명령 수와 속도가 항상 반비례하지는 않지만 이 경우는 numpy 배열의 메모리 지역성을 활용하여 캐시 미스가 줄어들어 명령 실행이 효율화된 것으로 볼 수 있습니다.

 

Vectorization

Numpy 연산이 매우 빠른 이유는 무엇일까요? 다음과 같이 50만개의 배열에 대해 numpy array 연산으로 1을 더하는 것과 모든 element를 for 문으로 순회하면서 1을 더하는 것은 시간 상의 명백한 차이가

hongl.tistory.com

실제로 numpy 성능 향상에서 가장 큰 영향을 미치는 부분은 벡터 연산보다는 메모리 단편화의 감소와 메모리 지역성입니다. numpy에서 벡터화를 뺴고 다른 부분을 그대로 둔 채 성능을 살펴봐도 캐시 미스 개선으로 인해 순수 파이썬 버전보다 훨씬 나은 성능을 보여줍니다. (벡터 연산 기능을 제외한 numpy를 빌드하기 위해선 "-fno-tree-vectorize" 플래그를 지정하면 됩니다.)

이 사실은 속도 병목을 해결하기 위해서는 연산 효율성보다는 메모리 문제에 우선적으로 접근해야 함을 알려줍니다. 이는 어찌보면 당연한 것이 애초에 컴퓨터는 수를 곱하고 더하는 문제를 정확히 계산할 목적으로 설계되었기 때문에 CPU가 계산을 해내는 속도보다 계산하려는 수를 읽어오는 속도가 더 느릴 가능성이 높겠죠. 그렇다면 현재 numpy 구현에서 메모리 측면을 어떻게 최적화할 수 있을까요?

 


행렬과 벡터 연산 (5) - numpy 배열 메모리 최적화

행렬과 벡터 연산 (6) - numexpr 모듈 이용하기

반응형