次の方法で共有


Windows と C++

VISUAL C++ 2010 と Parallel Pattern Library

Kenny Kerr

Kenny Kerr

このコラムは、Visual Studio 2010 のプレリリース バージョンに基づいています。記載されているすべての情報は、変更される場合があります。

目次

言語拡張
並列アルゴリズム
タスクとタスク グループ

Visual C++ は、Visual Studio 2010 のリリースで大幅にアップグレードされます。言語やライブラリの新しい機能の多くは、開発者の要望をより簡単に、より自然にコードで表現できるようにすることだけを考えて設計されています。しかし、これまでと同様、これらの機能を組み合わせることで C++ は非常に強力で表現力に富んだ言語になります。

今月は、間もなく公開される C++0x 標準の一部として Visual C++ に追加される C++ 言語の機能をいくつか紹介します。その後、アプリケーションに並列処理を導入するために Microsoft が標準 C++ ライブラリを自然に補完する形で C++0x 標準の上に開発した Parallel Pattern Library (PPL) について説明します。

言語拡張

2008 年 5 月の記事「C++ プラス : Visual C++ 2008 Feature Pack を使用して Windows アプリケーションを強化する」では、Visual C++ 2008 Feature Pack で最初に導入され、現在は Visual Studio 2008 SP1 に付属している Technical Report 1 (TR1) の一部として標準 C++ ライブラリに追加された機能について紹介しました。また、関数テンプレート クラスと bind テンプレート関数を使用した、関数オブジェクトのサポートについても説明しました。関数をポリモーフィックに扱えることで、C++ 開発者がジェネリック アルゴリズムの記述時や使用時によく遭遇する厄介な問題の多くを解決できます。

このことを念頭に置いて、標準のプラス アルゴリズムを使用して、関数オブジェクトを初期化する方法の例を次に示します。

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);

これらのライブラリの追加機能を使用すると、言語拡張なしでは簡単に解決できない問題が 2 つ発生します。1 つは、関数オブジェクトを明示的に定義することによって、コンパイラが回避できたはずのオーバーヘッドが発生するため、多くの場合、非効率になることです。もう 1 つは、初期化式に最適なシグネチャがコンパイラに明確にわかる場合は、関数プロトタイプの再宣言が行われるため、非常に冗長で、手間がかかることです。

ここで役に立つのが新しい自動キーワードです。これは、変数の型を明示的に定義する代わりに使用できるため、特定の型の定義が難しいか、表現が複雑なテンプレート メタプログラミングに役立ちます。次に例を示します。

auto f = plus<int>();

関数の定義自体も機能強化の恩恵を受けることができます。多くの場合、標準 C++ ライブラリなどに含まれている便利なアルゴリズムを再利用できます。しかし、一般的に、非常に限定的な目的に使用するドメイン固有の関数は再利用できないため、記述する必要があります。

ただし、関数は他の場所で定義する必要があるため、論理設計と物理設計の両方を考慮する必要があります。定義を必要な場所に配置できたら、ロジックの局所性と設計全体のカプセル化が改善され、コードが理解しやすくなるので、すばらしいと思いませんか。次のようにラムダ式の追加機能を使用することで、いま挙げたことが可能になります。

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

ラムダ式では、クロージャとも呼ばれる、名前のない関数オブジェクトを定義します。"[]" は、コンパイラにラムダ式が始まることを示すための記号です。これはラムダ導入子と呼ばれ、その後にはパラメータ リストが続きます。このパラメータ宣言に戻り値の型を含めることもできますが、コンパイラが型を明確に推測できる場合は、上記のコードのように省略することがよくあります。実際のところ、関数がパラメータを受け取らない場合は、パラメータ リスト自体省略できます。ラムダ式の最後には、中かっこで囲んだ任意の数の C++ ステートメントを記述します。

また、ラムダ式は関数内で使用する変数をそのラムダ式が定義されているスコープから取得できます。ラムダ式を使用して、コンテナ内の値の合計を計算する例を次に示します。

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

確かに、累計関数を使用すればもっと簡潔に実行できますが、それでは焦点がずれてしまいます。この例では、sum 変数を参照で取得し、関数内で使用する方法を示しています。

並列アルゴリズム

PPL によって、一連のタスク指向の並列処理コンストラクトと、現在 OpenMP で利用できるものに類似したいくつかの並列アルゴリズムが導入されます。PPL アルゴリズムはプラグマ ディレクティブではなく、C++ テンプレートを使用して記述されるため、非常に表現力と柔軟性に富んでいます。しかし、PPL は一連のパターンとして構成および再利用しやすい一連のプリミティブとアルゴリズムを奨励しているという点で、根本的に OpenMP とは異なります。一方、OpenMP はスケジューリングなどに関しては本質的により宣言的で明示的です。そして、何よりも C++ 本体に組み込まれていません。また、PPL は Concurrency Runtime 上に構築されているため、同じランタイムに基づく他のライブラリとのより強力な相互運用性を実現できます。まずは、PPL アルゴリズムについて説明します。その後、タスク指向の並列処理を行うために、基になる機能を直接使用する方法を紹介します。

2008 年 10 月号に掲載されたこのコラム (「高性能アルゴリズムについて調べる」) で、効率的なアルゴリズムの利点と、局所性とキャッシュを意識した設計がパフォーマンスに与える影響について説明しました。また、大きな画像をグレースケールに変換するのに、非効率的なシングルスレッドのアルゴリズムでは 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 を非常に自然に置き換えることができる並列アルゴリズムが含まれています。

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

ご覧のとおり、for ループと OpenMP プラグマが parallel_for 関数に置き換えられています。この関数の最初の 2 つのパラメータで、元の for ループと同じように、反復範囲を定義しています。OpenMP の for ディレクティブには厳しい制約がありますが、parallel_for はテンプレート関数なので、符号なし型や標準コンテナの複雑な反復子でも反復処理できます。最後のパラメータはオブジェクト関数で、筆者はこれをラムダ式と定義しています。

ラムダ導入子に含まれているのはアンパサンドだけで、取得する変数を明示的に宣言していないことがわかると思います。アンパサンドを指定すると、コンパイラは参照で取得可能な変数をすべて取得します。通常、ラムダ式内のステートメントには変数をいくつか指定しますが、簡単にするためにアンパサンドを使用しました。ただし、コンパイラは未使用のすべての変数を最適化できないため、実行時のパフォーマンスが低下することに注意してください。次の取得リストを指定することで、必要な変数を明示的に取得することもできます。

 [&bitmap, width, stride]

インデックス範囲を繰り返し並列処理する for ループの代替機能として parallel_for 関数があるように、標準の for_each 関数の代替機能として parallel_for_each テンプレート関数があります。この関数は、標準コンテナなどで提供される 1 組の反復子によって定義された要素の範囲を繰り返し並列処理します。前の例では、インデックスを明示的に指定して parallel_for 関数を使用する方が理にかなっていますが、ほとんどの場合は、要素の範囲を定義する反復子を使用する方が適しています。たとえば、次に示すように、parallel_for 関数を使用して数字の配列の各値を 2 乗できます。

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;
});

いくつかの関数を 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 が提供する別のヘルパ クラスも使用しています。この combinable クラスを使用すると、最小限のロックでさまざまな並列タスクの結果を驚くほど簡単に結合できます。combinable クラスでは、値のローカル コピーを各スレッドに渡し、並列処理が終了した後に各スレッドの結果を結合することで、そのような場合に通常発生するロックのほとんどを回避しています。

タスクとタスク グループ

これまでに説明したアルゴリズムの実際の並列処理は、自由に直接使用できる簡単なタスク指向の 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();

Parallel Pattern Library が Visual C++ 2010 と共に最終的にリリースされるときには、この記事で説明したアルゴリズムや API 以外の並列アルゴリズムやヘルパ クラスも含まれているでしょう。同時実行の最新情報については、「Parallel Programming in Native Code (ネイティブ コードの並列プログラミング)」を参照してください。

ご意見やご質問は mmwincpp@microsoft.com まで英語でお送りください。

Kenny Kerr は、Windows 向けのソフトウェア開発を専門にしているソフトウェア設計者です。彼はプログラミングおよびソフトウェア設計に関して執筆を行い、開発者を指導しています。連絡先は weblogs.asp.net/kennykerr です。