Windows With C++

Visual C++ 2010과 병렬 패턴 라이브러리

Kenny Kerr

이 칼럼은 Visual Studio 2010 시험판 버전을 기준으로 합니다. 여기에 포함된 모든 정보는 변경될 수 있습니다.

목차

향상된 언어 기능
병렬 알고리즘
작업 및 작업 그룹

Visual C++는 Visual Studio 2010 릴리스에서 대폭 업그레이드됩니다. 프로그래머가 자신의 의도를 보다 쉽고 자연스럽게 코드에 표현할 수 있도록 하기 위해 다양하고 새로운 언어 및 라이브러리 기능이 설계되고 있습니다. C++가 항상 그랬듯이 이러한 기능의 조합은 C++를 강력하고 표현성이 뛰어난 언어로 만들어 줍니다.

이번 달에는 앞으로 나올 C++0x 표준의 일부로 Visual C++가 추가한 새로운 C++ 언어 기능의 일부를 소개합니다. 그런 다음 Microsoft가 표준 C++ 라이브러리를 자연스럽게 보완하는 방식으로 응용 프로그램에 병렬성을 부여하기 위해 C++0x 표준에 더해 개발한 PPL(병렬 패턴 라이브러리)을 살펴보겠습니다.

향상된 언어 기능

2008년 5월 기사 "C++ Plus: Visual C++ 2008 Feature Pack을 사용하여 Windows 응용 프로그램에 새로운 기능 추가"에서 필자는 Visual C++ 2008 Feature Pack에 처음 등장해 현재는 Visual Studio 2008 SP1에 포함된, TR1(Technical Report 1)의 일부로 표준 C++ 라이브러리에 추가된 기능을 소개했습니다. 당시 기사에서 필자는 함수 템플릿 클래스와 bind 템플릿 함수를 통한 함수 개체 지원을 설명했습니다. 함수를 다형적으로 취급할 수 있게 되면서 C++ 개발자가 generic 알고리즘을 작성하거나 사용할 때 자주 직면하는 귀찮은 문제가 해결되었습니다.

상기를 위해 표준 plus 알고리즘을 사용하여 함수 개체를 초기화하는 방법의 예를 보겠습니다.

function<int (int, int)> f = plus<int>();
int result = f(4, 5);

bind 함수을 사용하면 필요한 기능을 제공하지만 적절한 용법이 없는 함수를 변환할 수 있습니다.

다음 예에서는 bind 함수를 자리 표시자와 함께 사용하여 멤버 함수로 함수 개체를 초기화합니다.

struct Adder
{
   int Add(int x, int y, void* /*reserved*/)
   {
       return x + y;
   }
};

Adder adder;
function<int (int, int)> f = bind(&Adder::Add, &adder, _1, _2, 
    static_cast<void*>(0));
int result = f(4, 5);

이러한 라이브러리 추가 기능을 사용할 경우 두 가지 문제가 발생하는데, 이 문제는 향상된 언어 기능이 없다면 쉽게 해결할 수 없습니다. 우선 함수 개체를 명시적으로 정의하는 것만으로는 부족한 경우가 많습니다. 컴파일러에 불필요한 오버헤드가 추가되기 때문입니다. 또한 컴파일러가 초기화 식에 가장 적합한 용법을 명확히 알고 있는 상태에서 함수 프로토타입을 다시 선언하는 일은 반복적이고 지루한 작업일 수 있습니다.

바로 이 부분에서 새로운 auto 키워드가 도움이 됩니다. 이 키워드는 변수의 형식을 명시적으로 정의하는 대신 사용할 수 있는데, 이는 특정 형식을 정의하기 어렵거나 표현하기가 복잡한 템플릿 메타프로그래밍에 유용합니다. 형태는 다음과 같습니다.

auto f = plus<int>();

함수 선언 자체도 개선된 기능을 통해 이점을 얻을 수 있습니다. 여러분은 표준 C++ 라이브러리의 알고리즘과 같은 유용한 알고리즘을 재사용하는 경우가 많습니다. 한편 매우 구체적인 목적을 위한 도메인 관련 함수를 작성해야 하는 경우가 종종 있는데, 이러한 함수는 일반적으로 재사용할 수 없습니다.

그러나 함수를 다른 곳에 정의해야 하므로 여러분은 논리적 설계와 물리적 설계를 모두 고려해야 합니다. 정의가 필요한 바로 그 위치에 정의를 둘 수 있다면, 그렇게 해서 논리의 지역성을 강화하고 전체적인 설계의 캡슐화를 개선하여 코드의 이해를 간소화할 수 있다면 좋지 않을까요? 람다 식을 추가하면 가능합니다.

auto f = [](int x, int y) { return x + y; };

람다 식은 명명되지 않은 함수 개체(클로저라고 함)를 정의합니다. [] 부분은 컴파일러에게 람다 식이 시작되었음을 알리는 힌트입니다. 이 부분을 람다 도입부(lambda introducer)라고 하며 그 뒤에는 매개 변수 목록이 나옵니다. 이 매개 변수 선언에는 반환 형식이 포함될 수도 있지만 반환 형식은 컴파일러가 명확하게 그 형식을 추정할 수 있는 경우 대부분 생략됩니다. 위의 코드 조각이 그러한 예입니다. 사실 함수가 매개 변수를 받지 않는다면 매개 변수 목록 자체를 생략할 수 있습니다. 람다 식은 중괄호 안에 들어간 C++ 문으로 끝납니다(문의 수는 관계없음).

또한 람다 식은 람다 식이 정의된 범위에서 함수 내에 사용하기 위한 변수를 캡처할 수도 있습니다. 다음은 람다 식을 사용하여 컨테이너에 있는 값의 합을 계산하는 예입니다.

int sum = 0;
for_each(values.begin(), values.end(), [&sum](int value)
{
    sum += value;
});

물론 accumulate 함수를 사용하면 이 작업을 더 간결하게 수행할 수 있지만 그러면 요점을 놓치게 됩니다. 이 예는 sum 변수가 참조에 의해 캡처되어 함수 내에서 사용되는 방법을 보여 줍니다.

병렬 알고리즘

PPL은 작업 지향 병렬 처리 구문과, 현재 OpenMP에서 제공하는 것과 비슷한 몇 가지 병렬 알고리즘을 제공합니다. 그러나 PPL 알고리즘은 pragma 지시문이 아닌 C++ 템플릿을 사용하여 작성되며 그 결과 표현성과 유연성이 훨씬 더 뛰어납니다. PPL은 패턴 집합으로의 조합 가능성과 재사용성이 더 높은 일련의 기본형 및 알고리즘 집합을 장려한다는 측면에서 근본적으로 OpenMP와 다릅니다. 반면 OpenMP는 예약과 같은 부분에서 기본적으로 더 선언적이고 명시적이며, 엄밀하게 보면 결국 C++에 속하지 않습니다. 또한 PPL은 Concurrency Runtime을 기반으로 구축되므로 동일한 런타임을 기반으로 하는 다른 라이브러리와 훨씬 더 높은 잠재적 상호 운용성이 제공됩니다. PPL 알고리즘을 살펴본 후에 작업 지향 병렬 처리에 직접 그 기본적인 기능을 사용하는 방법을 알아보겠습니다.

2008년 10월호 Windows With C++ 칼럼("고성능 알고리즘 탐구")에서 필자는 효율적인 알고리즘의 장점과 지역성 및 캐시 인식 디자인이 성능에 미치는 영향을 설명했습니다. 또한 비효율적인 단일 스레드 알고리즘은 큰 이미지를 회색조로 변환하는 데 46초가 걸리지만 효율적인 구현은 하나의 스레드만 사용하여 2초만에 이 작업을 수행할 수 있음을 살펴보았습니다. OpenMP를 약간 활용하여 필자는 Y축에 대해 알고리즘을 병렬화함으로써 이 시간을 더 줄일 수 있었습니다. 그림 1은 OpenMP 알고리즘의 코드를 보여 줍니다.

그림 1 OpenMP를 사용한 회색조 알고리즘

struct Pixel
{
    BYTE Blue;
    BYTE Green;
    BYTE Red;
    BYTE Alpha;
};

void MakeGrayscale(Pixel& pixel)
{
    const BYTE scale = static_cast<BYTE>(0.30 * pixel.Red +
                                         0.59 * pixel.Green +
                                         0.11 * pixel.Blue);

    pixel.Red = scale;
    pixel.Green = scale;
    pixel.Blue = scale;
}

void MakeGrayscale(BYTE* bitmap,
                   const int width,
                   const int height,
                   const int stride)
{
    #pragma omp parallel for
    for (int y = 0; y < height; ++y)
    {
        for (int x = 0; x < width; ++x)
        {
            const int offset = x * sizeof(Pixel) + y * stride;

            Pixel& pixel = *reinterpret_cast<Pixel*>(bitmap + offset);

            MakeGrayscale(pixel);
        }
    }
}

PPL에는 람다 식의 도움을 받아 그림 1의 MakeGrayscale 함수에서 OpenMP 사용을 매끄럽게 대체할 수 있는 병렬 for 알고리즘이 포함되어 있습니다.

parallel_for(0, height, [&] (int y)
{
    for (int x = 0; x < width; ++x)
    {
        // omitted for brevity
    }
});

여기에서 볼 수 있듯이 for 루프와 OpenMP pragma는 parallel_for 함수로 대체되었습니다. 함수의 처음 두 매개 변수는 이전의 for 루프에서와 마찬가지로 반복의 범위를 정의합니다. for 지시문에 강한 제약 조건을 두는 OpenMP와 달리 parallel_for는 템플릿 함수이므로 예를 들어 부호 없는 형식에 대해 반복하거나 심지어 표준 컨테이너의 복잡한 반복기에 대해 반복할 수도 있습니다. 마지막 매개 변수는 함수 개체이며 람다 식으로 정의합니다.

람다 도입부에는 캡처할 변수의 명시적 선언 없이 앰퍼샌드만 포함되어 있습니다. 이는 컴파일러에게 참조별로 가능한 모든 변수를 캡처할 것을 지시합니다. 람다 식 내의 문은 여러 변수를 사용하므로 이를 축약형으로 사용했습니다. 그러나 컴파일러는 사용되지 않는 모든 변수를 최적화할 수는 없으므로 런타임 성능이 떨어질 수 있다는 점을 유의해야 합니다. 다음 캡처 목록을 사용했다면 필요한 변수를 명시적으로 캡처할 수 있었을 것입니다.

 [&bitmap, width, stride]

parallel_for 함수가 인덱스 범위에 대해 병렬 반복을 제공하는 for 루프에 대한 병렬적 대안이듯이, PPL은 표준 for_each 함수에 대한 병렬적 대안으로 parallel_for_each 템플릿 함수도 제공합니다. 이 함수는 표준 컨테이너가 제공하는 것과 같은 한 쌍의 반복기로 정의되는 요소 범위에 대한 병렬 반복을 제공합니다. 앞의 예에서는 명시적 인덱스와 함께 parallel_for 함수를 사용하는 것이 더 적절하지만 반복기를 사용하여 요소의 범위를 정의하는 편이 더 자연스러운 경우가 많습니다. 숫자 배열의 경우 다음과 같이 parallel_for 함수를 사용하여 이 배열의 값을 제곱할 수 있습니다.

array<int, 5> values = { 1, 2, 3, 4, 5 };

parallel_for(0U, values.size(), [&values] (size_t i)
{
    values[i] *= 2;
});

그러나 이 방법은 너무 장황하며 배열 자체가 람다 식에 의해 캡처되어야 하고 컨테이너의 형식에 따라 비효율적일 수도 있습니다. parallel_for_each 함수는 이러한 문제를 가볍게 해결합니다.

parallel_for_each(values.begin(), values.end(), [] (int& value)
{
    value *= 2;
});

단지 몇 개의 함수를 병렬로 실행하거나, 가용한 하드웨어 스레드의 수에 따라 최대한 병렬화하는 것이 목적이라면 parallel_invoke 템플릿 함수를 사용하면 됩니다. 2 ~ 10개의 함수 개체를 수용할 수 있는 오버로드가 있습니다. 다음은 3개의 람다 함수를 병렬로 실행하는 예입니다.

combinable<int> sum;

parallel_invoke([&] { sum.local() += 1; },
                [&] { sum.local() += 2; },
                [&] { sum.local() += 3; });

int result = sum.combine([] (int left, int right)
{
    return left + right;
});

ASSERT(6 == result);

이 예는 PPL이 제공하는 또 다른 도우미 클래스도 보여 줍니다. 조합 가능한 클래스 덕분에 최소한의 잠금으로 여러 병렬 작업의 결과를 조합하기가 무척 쉬워집니다. 조합 가능한 클래스는 각 스레드에 값의 로컬 복사본을 제공한 다음 병렬 작업이 완료된 후 각 스레드의 결과를 조합하는 방법으로 이러한 경우에 일반적으로 발생하는 잠금을 대부분 피할 수 있습니다.

작업 및 작업 그룹

여기서 설명한 알고리즘의 실제 병렬 처리는 여러분이 자유롭게 직접 사용할 수 있는 간단한 작업 지향 API를 통해 수행됩니다. 작업은 함수 개체로 초기화되는 task_handle 클래스로 정의됩니다. 작업은 작업을 실행하고 완료되기를 기다리는 task_group 클래스로 그룹화됩니다. 물론 task_group은 유용한 오버로드를 제공하므로 많은 경우 task_handle 개체를 직접 정의할 필요가 없으며 task_group 개체가 자동으로 수명을 할당하고 관리하도록 할 수 있습니다. 다음 예에서는 task_group을 사용하여 이전 예의 parallel_invoke 함수를 대체하는 방법을 보여 줍니다.

task_group group;
group.run([&] { sum.local() += 1; });
group.run([&] { sum.local() += 2; });
group.run([&] { sum.local() += 3; });
group.wait();

Visual C++ 2010과 함께 병렬 패턴 라이브러리가 최종 출시될 때는 여기서 설명한 알고리즘과 API 외에 다른 병렬 알고리즘 및 도우미 클래스도 포함될 것입니다. 동시성에 대한 최신 정보를 보려면 네이티브 코드로 병렬 프로그래밍을 방문하십시오.

질문이나 의견이 있으시면 mmwincpp@microsoft.com으로 보내시기 바랍니다.

Kenny Kerr는 Windows용 소프트웨어를 전문적으로 개발하는 소프트웨어 개발자로, 프로그래밍과 소프트웨어 설계 분야에서 왕성한 집필 및 교육 활동을 하고 있습니다. 문의 사항이 있으면 weblogs.asp.net/kennykerr를 방문하시기 바랍니다.