본문 바로가기

Develop/.NET 가이드

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

반응형

병렬 프로그래밍
Parallel Programming

병렬 프로그래밍 Parallel Programming

C++ 시절 병렬 프로그래밍은 하드웨어 수준의 조작이 필요해 굉장히 어려웠고 버그도 많았습니다. 더 큰 문제는 병렬 프로그래밍을 위한 디버깅 도구도 마땅치 않았으며, 병렬 프로그래밍에 능숙한 개발자도 없어 팀으로 일하는건 불가능했습니다. 하지만 Visual Studio 의 뛰어난 기능과 편리한 라이브러리의 등장으로 병렬 프로그래밍에 입문하기 굉장히 쉬워졌습니다.

시작하기에 앞서...

개발자가 굉장히 쉽게 오해하는 부분이 병렬 프로그래밍을 비동기 프로그래밍 개념의 확장으로 이해하는 부분입니다.

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

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

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

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

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

병렬 프로그래밍은 크게 데이터 병렬화 (Data Parallelism)작업 병렬화(Task Parallelism) 로 나뉩니다.

데이터 병렬화 Data Parallelism

데이터 병렬화는 동일한 처리를 다수의 데이터에 적용하는 방식입니다. 서로 다른 데이터를 각 중앙처리장치 코어에 나누어 동일한 처리를 합니다. 쉽게 설명드리자면, 리스트나 배열에 있는 모든 데이터에 For 문으로 동일한 작업을 할 때 각 코어 별로 작업을 나누어 처리합니다. 데이터 병렬 처리는 System.Threading.Tasks.Parallel 클래스를 사용합니다. 아래 코드 예제는 간단한 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 와 같은 공유 리소스를 사용하면 성능이 제대로 측정되지 않으니 주의바랍니다. 공유 리소스를 동기 호출하게 되면 병렬 처리의 성능을 상당히 저하시키기 때문에 측정 결과가 왜곡될 수 있습니다.

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

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

스레드 로컬 변수 활용하기

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

Parallel.For 메서드의 경우, 아래와 오버로드 방식으로 스레드 로컬 변수를 사용합니다.

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

public static ParallelLoopResult ForEach<TSource,TLocal> (
    IEnumerable<TSource> source,
    Func<TLocal> localInit,
    Func<TSource, ParallelLoopState, TLocal, TLocal> body,
    Action<TLocal> localFinally
);

아래는 예제 코드입니다.

         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)
         );
반응형