11

MSVC 2010 デバッグ モードで OpenMP を使用してビルドすると、奇妙な動作が発生するかなり複雑なプログラムがあります。実際のプログラムの構造を模倣した次の最小限の作業例 (実際には最小限ではありません) を作成するために最善を尽くしました。

#include <vector>
#include <cassert>

// A class take points to the whole collection and a position Only allow access
// to the elements at that posiiton. It provide read-only access to query some
// information about the whole collection
class Element
{
    public :

    Element (int i, std::vector<double> *src) : i_(i), src_(src) {}

    int i () const {return i_;}
    int size () const {return src_->size();}

    double src () const {return (*src_)[i_];}
    double &src () {return (*src_)[i_];}

    private :

    const int i_;
    std::vector<double> *const src_;
};

// A Base class for dispatch
template <typename Derived>
class Base
{
    protected :

    void eval (int dim, Element elem, double *res)
    {
        // Dispatch the call from Evaluation<Derived>
        eval_dispatch(dim, elem, res, &Derived::eval); // Point (2)
    }

    private :

    // Resolve to Derived non-static member eval(...)
    template <typename D>
    void eval_dispatch(int dim, Element elem, double *res,
            void (D::*) (int, Element, double *))
    {
#ifndef NDEBUG // Assert that this is a Derived object
        assert((dynamic_cast<Derived *>(this)));
#endif
        static_cast<Derived *>(this)->eval(dim, elem, res);
    }

    // Resolve to Derived static member eval(...)
    void eval_dispatch(int dim, Element elem, double *res,
            void (*) (int, Element, double *))
    {
        Derived::eval(dim, elem, res); // Point (3)
    }

    // Resolve to Base member eval(...), Derived has no this member but derived
    // from Base
    void eval_dispatch(int dim, Element elem, double *res,
            void (Base::*) (int, Element, double *))
    {
        // Default behavior: do nothing
    }
};

// A middle-man who provides the interface operator(), call Base::eval, and
// Base dispatch it to possible default behavior or Derived::eval
template <typename Derived>
class Evaluator : public Base<Derived>
{
    public :

    void operator() (int N , int dim, double *res)
    {
        std::vector<double> src(N);
        for (int i = 0; i < N; ++i)
            src[i] = i;

#pragma omp parallel for default(none) shared(N, dim, src, res)
        for (int i = 0; i < N; ++i) {
            assert(i < N);
            double *r = res + i * dim;
            Element elem(i, &src);
            assert(elem.i() == i); // Point (1)
            this->eval(dim, elem, r);
        }
    }
};

// Client code, who implements eval
class Implementation : public Evaluator<Implementation>
{
    public :

    static void eval (int dim, Element elem, double *r)
    {
        assert(elem.i() < elem.size()); // This is where the program fails Point (4)
        for (int d = 0; d != dim; ++d)
            r[d] = elem.src();
    }
};

int main ()
{
    const int N = 500000;
    const int Dim = 2;
    double *res = new double[N * Dim];
    Implementation impl;
    impl(N, Dim, res);
    delete [] res;

    return 0;
}

vector実際のプログラムにはetcはありませんが、 ElementBaseEvaluatorおよびImplementationは実際のプログラムの基本的な構造を捉えています。デバッグ モードでビルドし、デバッガーを実行すると、アサーションが で失敗しPoint (4)ます。

コール スタックを表示して、デバッグ情報の詳細を次に示します。

に入るPoint (1)と、ローカルiには値がありますが371152、これで問題ありません。変数elemはフレームに表示されません。これは少し奇妙です。でも at のアサーションPoint (1)は失敗しないので大丈夫だと思います。

その後、クレイジーなことが起こりました。evalbyへの呼び出しはEvaluatorその基本クラスに解決されるため、実行Point (2)されました。この時点で、デバッガーは has を示していelemます。これは、値によってに渡す前に作成するために使用されなくi_ = 499999なりました。次のポイントは に解決されますが、今回はがあり、これは範囲外です。これは、コールが に向けられ、アサーションに失敗したときの値です。ielemEvaluatorBase::evalPoint (3)elemi_ = 501682Point (4)

Elementオブジェクトが値渡しされるたびに、そのメンバーの値が変更されるように見えます。プログラムを複数回再実行すると、常に再現できるとは限りませんが、同様の動作が発生します。実際のプログラムでは、このクラスはイテレータのように設計されており、パーティクルのコレクションを反復処理します。それが反復するものはコンテナのように正確ではありませんが。とにかく、ポイントは、値によって効率的に渡されるのに十分小さいということです。したがって、クライアント コードは、何らかの参照またはポインターの代わりに の独自のコピーがあることを認識しており、書き込みアクセスのみを提供する のインターフェイスElementに固執する限り、スレッド セーフについて (あまり) 心配する必要はありません。Elementコレクション全体の単一の位置に。

GCC と Intel ICPC で同じプログラムを試しました。予期しないことは何も起こりません。実際のプログラムでは、正しい結果が生成されます。

どこかで OpenMP を間違って使用しましたか? elem作成された at aboutPoint (1)はループ本体に対してローカルになると思いました。また、プログラム全体では、N生成された以上の価値はありませんが、その新しい価値はどこから来るのでしょうか?

編集

デバッガーをより注意深く調べたところ、値で渡されelem.i_たときに変更されたのにelem、ポインターelem.src_が変更されていないことがわかりました。値渡し後、(メモリアドレスの) 同じ値を持つ

編集:コンパイラフラグ

CMake を使用して MSVC ソリューションを生成しました。正直に言うと、MSVC や Windows の一般的な使い方がわかりません。私がそれを使用している唯一の理由は、多くの人がそれを使用していることを知っているため、問題を回避するためにライブラリをテストしたいからです.

CMake で生成されたプロジェクトは、Visual Studio 10 Win64ターゲットを使用して、コンパイラ フラグが次のように表示されます /DWIN32 /D_WINDOWS /W3 /Zm1000 /EHsc /GR /D_DEBUG /MDd /Zi /Ob0 /Od /RTC1。プロパティ ページ - C/C++ - コマンド ラインにあるコマンド ラインは次のとおりです。 /Zi /nologo /W3 /WX- /Od /Ob0 /D "WIN32" /D "_WINDOWS" /D "_DEBUG" /D "CMAKE_INTDIR=\"Debug\"" /D "_MBCS" /Gm- /EHsc /RTC1 /MDd /GS /fp:precise /Zc:wchar_t /Zc:forScope /GR /openmp /Fp"TestOMP.dir\Debug\TestOMP.pch" /Fa"Debug" /Fo"TestOMP.dir\Debug\" /Fd"C:/Users/Yan Zhou/Dropbox/Build/TestOMP/build/Debug/TestOMP.pdb" /Gd /TP /errorReport:queue

ここで疑わしいものはありますか?

4

1 に答える 1

8

どうやら MSVC の 64 ビット OpenMP 実装は、最適化なしでコンパイルされたコードと互換性がありません。

問題をデバッグするためthreadprivateに、呼び出しの直前に反復回数をグローバル変数に保存するようにコードを変更し、保存された反復回数が と異なるかどうかをthis->eval()確認するために の先頭にチェックを追加しました。Implementation::eval()elem.i_

static int _iter;
#pragma omp threadprivate(_iter)

...
#pragma omp parallel for default(none) shared(N, dim, src, res)
    for (int i = 0; i < N; ++i) {
        assert(i < N);
        double *r = res + i * dim;
        Element elem(i, &src);
        assert(elem.i() == i); // Point (1)
        _iter = i;             // Save the iteration number
        this->eval(dim, elem, r);
    }
}
...

...
static void eval (int dim, Element elem, double *r)
{
    // Check for difference
    if (elem.i() != _iter)
        printf("[%d] _iter=%x != %x\n", omp_get_thread_num(), _iter, elem.i());
    assert(elem.i() < elem.size()); // This is where the program fails Point (4)
    for (int d = 0; d != dim; ++d)
        r[d] = elem.src();
}
...

elem.i_の値は、さまざまなスレッドで に渡された値のランダムな混合物になるようvoid eval_dispatch(int dim, Element elem, double *res, void (*) (int, Element, double *))です。これは各実行で何百回も発生しますが、値がelem.i_アサーションをトリガーするのに十分な大きさになった場合にのみ発生します。混合値がコンテナーのサイズを超えず、コードがアサーションなしで実行を完了する場合があります。また、アサーション後のデバッグ セッション中に表示されるのは、VS デバッガーがマルチスレッド コードを適切に処理できないことです:)

これは、最適化されていない 64 ビット モードでのみ発生します。32 ビット コード (デバッグとリリースの両方) では発生しません。また、最適化が無効になっていない限り、64 ビット リリース コードでも発生しません。this->eval()への呼び出しをクリティカル セクションに配置した場合にも発生しません。

#pragma omp parallel for default(none) shared(N, dim, src, res)
    for (int i = 0; i < N; ++i) {
        ...
#pragma omp critical
        this->eval(dim, elem, r);
    }
}

しかし、これを行うと、OpenMP の利点が取り消されます。これは、呼び出しチェーンのさらに下にある何かが安全でない方法で実行されていることを示しています。アセンブリ コードを調べましたが、正確な理由が見つかりませんでした。ElementMSVC は単純なビットごとのコピー (インラインであっても) を使用してクラスの暗黙的なコピー コンストラクターを実装し、すべての操作はスタック上で行われるため、私は本当に困惑しています。

これは、Sun の (現在の Oracle の) コンパイラが、OpenMP サポートを有効にする場合、最適化のレベルを上げるべきだと主張しているという事実を思い出させます。残念ながら、/openmpMSDN のオプションのドキュメントには、「間違った」最適化レベルから生じる可能性のある干渉については何も書かれていません。これもバグかもしれません。VS にアクセスできる場合は、別のバージョンの VS でテストする必要があります。

編集:約束どおりさらに深く掘り下げ、Intel Parallel Inspector 2011 でコードを実行しました。予想どおり、1 つのデータ競合パターンが見つかりました。どうやらこの行が実行されると:

this->eval(dim, elem, r);

の一時コピーが作成され、Windows x64 ABI で必要とされるようelemにアドレスによってメソッドに渡されます。eval()そして、ここで奇妙なことが起こります: この一時コピーの場所は、予想されるように、並列領域を実装するファンクレットのスタック上ではなく (ちなみに、MSVC コンパイラはそれEvaluator$omp$1<Implementation>::operator()を呼び出します)、むしろそのアドレスが最初の引数として取得されます。ファンクレット。この引数はすべてのスレッドで同じであるため、さらに渡される一時コピーthis->eval()が実際にはすべてのスレッド間で共有されることを意味します。

...
void eval (int dim, Element elem, double *res)
{
    printf("[%d] In Base::eval()    &elem = %p\n", omp_get_thread_num(), &elem);
    // Dispatch the call from Evaluation<Derived>
    eval_dispatch(dim, elem, res, &Derived::eval); // Point (2)
}
...

...
#pragma omp parallel for default(none) shared(N, dim, src, res)
    for (int i = 0; i < N; ++i) {
        ...
        Element elem(i, &src);
        ...
        printf("[%d] In parallel region &elem = %p\n", omp_get_thread_num(), &elem);
        this->eval(dim, elem, r);
    }
}
...

このコードを実行すると、次のような出力が生成されます。

[0] Parallel region &elem = 000000000030F348 (a)
[0] Base::eval()    &elem = 000000000030F630
[0] Parallel region &elem = 000000000030F348 (a)
[0] Base::eval()    &elem = 000000000030F630
[1] Parallel region &elem = 000000000292F9B8 (b)
[1] Base::eval()    &elem = 000000000030F630 <---- !!
[1] Parallel region &elem = 000000000292F9B8 (b)
[1] Base::eval()    &elem = 000000000030F630 <---- !!

予想どおりelem、並列領域を実行する各スレッドに異なるアドレスがあります (ポイント(a)(b))。ただし、渡される一時コピーがBase::eval()各スレッドで同じアドレスを持つことに注意してください。これは、共有変数Elementを使用する暗黙のコピー コンストラクターを作成するコンパイラのバグだと思います。これは、 に渡されたアドレスを見ることで簡単に確認できます。これは、 のアドレスとのアドレスの間、つまり共有変数ブロックにあります。アセンブリ ソースをさらに調べると、一時的な場所のアドレスが、OpenMP fork/join モデルの fork 部分を実装する関数の引数として実際に渡されていることがわかります。Base::eval()Nsrc_vcomp_fork()vcomp100.dll

Base::eval()基本的に、 、Base::eval_dispatch()、およびImplementation::eval()すべてがインライン化される最適化を有効にする以外に、この動作に影響を与える可能性のあるコンパイラ オプションは存在しないため、 の一時的なコピーは作成されないelemため、私が見つけた唯一の回避策は次のとおりです。

1)参照Element elemへの引数を作成します。Base::eval()

void eval (int dim, Element& elem, double *res)
{
    eval_dispatch(dim, elem, res, &Derived::eval); // Point (2)
}

これによりelem、並列領域を実装する funclet のスタック内ののローカル コピーEvaluator<Implementation>::operator()が渡され、共有一時コピーは渡されないことが保証されます。これはさらに別の一時コピーとして値によって渡されますがBase::eval_dispatch()、この新しい一時コピーはBase::eval()共有変数ブロックではなくスタックにあるため、正しい値を保持します。

2) に明示的なコピー コンストラクターを提供しますElement

Element (const Element& e) : i_(e.i_), src_(e.src_) {}

ソース コードをさらに変更する必要がないため、明示的なコピー コンストラクターを使用することをお勧めします。

どうやら、この動作は MSVS 2008 にも存在するようです。MSVS 2012 にも存在するかどうかを確認し、MS にバグ レポートを提出する必要があります。

このバグは 32 ビット コードでは表示されません。値オブジェクトによって渡される各オブジェクトの値全体が、それへのポインタだけでなく、コール スタックにプッシュされるためです。

于 2012-07-17T18:21:25.950 に答える