본문 바로가기

C#

병렬 프로그래밍 Parallel Programming (1) - 데이터 병렬화

728x90

병렬 프로그래밍 Parallel Programming

병렬 프로그래밍은 비동기 프로그래밍이 아니다!

 

병렬 프로그래밍과 비동기 프로그래밍 모두 컴퓨터에게 제일 중요한 자원인 중앙처리장치(CPU)를 효율적으로 사용하기 위한 기법입니다. 다만 중앙처리장치를 효율적으로 사용하는 방법이 다를 뿐입니다. 병렬 프로그래밍은 작업을 분할 정복합니다. 반면 비동기 프로그래밍은 코드 흐름을 유연하게 변경합니다.

 

병렬 프로그래밍에서 주의할 점

소스 코드에 따라 병렬 처리가 적합하지 않을 수 있다. 특히 병렬 처리를 위해 드러나지 않는 사전 작업이 굉장히 많다. 따라서 단순한 작업을 위해 병렬 처리하는 건 오히려 실행 속도를 저하시킬 수 있다. 그리고 실행을 필요 이상으로 복잡하게 하여 디버깅을 어렵게 하기도 한다. 따라서 병렬 처리를 효과적으로 사용하려면 다중 스레드와 관련된 기본적인 개념을 반드시 이해하고 있어야 한다. 실전 병렬 프로그래밍에 뛰어 들기 전에 개념을 잘 모른다면  삼성 SDS 병렬컴퓨팅 스토리를 먼저 읽어 주시길 바랍니다.

 

.NET 은 병렬 프로그래밍으로 작업 병렬 라이브러리(TPL; Task Parallel Library)을 제공합니다. TPL은 System.Threading 및 System.Threading.Tasks 네임스페이스로 구성되어 있습니다. TPL 이 병렬 프로그래밍 입문으로 적합한 이유는 작업의 분할, 스레드 풀 관리 등을 개발자를 대신하여 관리해주기 때문입니다.

병렬 프로그래밍은 크게 데이터 병렬화와 작업 병렬화로 나뉜다.

 

데이터 병렬화 Data Parallelism

데이터 병렬화는 동일한 처리를 다수의 데이터에 적용하는 방식이다. 서로 다른 데이터를 각 중앙처리장치 코어에 나누어 동일한 처리를 한다. 쉽게 말하자면, 리스트나 배열에 있는 모든 데이터에 For문으로 동일한 작업을 할 때 각 코어 별로 작업을 나누어 처리한다. 아래 예제는 Parallel.ForEach 메서드 사용 방법이다.

// 직렬 처리          
foreach (var item in sourceCollection)
{
    Process(item);
}

// 병렬 처리
Parallel.ForEach(sourceCollection, item => Process(item));

위와 같이 병렬 프로그래밍이 간단한 코드로 표현될 수 있는 이유는 TPL이 개발자를 대신하여 스레드 상태를 모니터링, 유지 관리, 종료, 동시 실행 제어를 해주기 때문이다. 병렬 처리가 쉬워진 대신에, TPL 사용으로 발생하는 오버 헤드 비용이 병렬 처리로 발생하는 이익보다 크지 않도록 신경써줘야 한다.

 

행렬 곱 예제

아래 예제 코드를 찬찬히 살펴보면 바깥 루프만 병렬 처리한 것을 확인할 수 있다. 만약 내부 루프까지 모두 병렬로 처리할 경우, 중첩된 병렬 루프로 인해 의도와 반대로 성능 저하를 초래할 수 있다. 왜냐하면 내부 루프에서 많은 작업이 수행되지 않아 병렬 처리로 얻을 수 있는 이익이 적은 반면, 병렬 처리 오버 헤드로 인한 비용은 막대하기 때문이다.

static void MultiplyMatricesParallel(double[,] matA, double[,] matB, double[,] result)
{
    int matACols = matA.GetLength(1);
    int matBCols = matB.GetLength(1);
    int matARows = matA.GetLength(0);

    // A basic matrix multiplication.
    // Parallelize the outer loop to partition the source array by rows.
    Parallel.For(0, matARows, i =>
    {
        for (int j = 0; j < matBCols; j++)
        {
            double temp = 0;
            for (int k = 0; k < matACols; k++)
            {
                temp += matA[i, k] * matB[k, j];
            }
            result[i, j] = temp;
        }
    }); // Parallel.For
}

 

병렬 처리 성능 측정

병렬 처리로 인한 비용과 이익을 비교하려면 system.Diagnostics 과 성능 프로파일링 도구를 사용하면 된다. 성능 측정 시, Console.WriteLine와 같은 공유 리소스를 사용하면 성능이 제대로 측정되지 않으니 주의바란다. 공유 리소스를 동기 호출하게 되면 병렬 처리의 성능을 상당히 저하시키기 때문에 측정 결과가 왜곡될 수 있다.

 

병렬 처리 결과를 공유 메모리에 저장하기

병렬 처리로 인해 버그가 발생하는 주된 원인은 다수의 스레드가 공유 메모리에 접근하기 때문이다. 그렇지만 버그를 방지하기 위해 스레드를 지속적으로 동기화하면 병렬 처리 성능이 저하되어, 병렬 처리를 하는 의미가 없어진다. 그래서 제대로 된 병렬 처리를 하기 위해선 병렬 처리한 결과를 공유 메모리에 언제 어떻게 저장할지, 성능에 어떠한 영향을 미치는지 예상할 수 있어야 한다.

 

스레드 로컬 변수 활용하기

공유 메모리에 접근하는 횟수를 최소화 하는 방법 중 하나는 스레드 메모리를 활용하는 방법입니다. 공유 메모리가 아닌 스레드 메모리를 활용하면, 공유 메모리에 다수의 스레드가 데이터 작업을 하는 '데이터 레이스' 현상을 방지할 수 있습니다. 스레드 로컬 변수에 데이터를 저장하면서, 병렬 처리 마지막에만 공유 메모리에 접근하여 최종 데이터를 기록합니다. 따라서 공유 메모리에 접근하는 횟수를 최소화할 수 있습니다.

public static ParallelLoopResult For<TLocal> (
    int fromInclusive, 
    int toExclusive, 
    Func<TLocal> localInit, 
    Func<int,ParallelLoopState,TLocal,TLocal> body, 
    Action<TLocal> localFinally
);
int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0; // 공유 메모리 변수

Parallel.For<long>(
    // int fromInclusive : 시작
    0, 
    // int toExclusive : 끝
    nums.Length,
    // Func<TLocal> localInit : 스레드 로컬 변수 초기화 함수
    () => 0, 

    // Func<int,ParallelLoopState,TLocal,TLocal> body : 병렬 처리 함수
       (j, loop, subtotal) =>
    {
        subtotal += nums[j];
        return subtotal;
    },

    // Action<TLocal> localFinally : 스레드 로컬 변수를 공유 메모리 변수에 적용하는 함수
    (x) => Interlocked.Add(ref total, x)
);

InterLocked 클래스는 병렬 처리 도중 스레드로부터 안전하게 공유 메모리에 접근할 수 있는 연산을 제공한다.

Parallel.ForEach 루프의 경우는 "파티션 로컬 변수" 라고 말하지만, 스레드 로컬 변수와 동일하게 공유 메모리가 아닌 스레드 메모리에 접근하며 병렬 처리를 하고 마지막에만 공유 메모리에 접근하여 최종 데이터를 기록합니다.

int[] nums = Enumerable.Range(0, 1000000).ToArray();
long total = 0; // 공유 메모리 변수

Parallel.ForEach<int, long>(
            // IEnumerable<TSource> source : ForEach 루프를 진행할 데이터
           nums, 
           // Func<TLocal> localInit : 파티션 로컬 변수 초기화
           () => 0, 
           // Func<TSource, ParallelLoopState, TLocal, TLocal> body : 병렬 처리 함수
           (j, loop, subtotal) =>
           {
                   subtotal += j; // 로컬 변수에 접근
                   return subtotal; // 로컬 변수 반환
              },
           // Action<TLocal> localFinally : 파티션 로컬 변수를 공유 메모리 변수에 적용하는 함수
           (finalResult) => Interlocked.Add(ref total, finalResult)
);

 

 

[출처]

https://ibocon.tistory.com/118

728x90