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

真实程序没有vectoretc。但是Element, Base,EvaluatorImplementation捕获了真实程序的基本结构。在调试模式下构建并运行调试器时,断言在Point (4).

通过查看调用堆栈,这是调试信息的更多详细信息,

在进入Point (1)时,本地i具有价值371152,这很好。变量elem没有显示在框架中,这有点奇怪。但由于 at 的断言Point (1)没有失败,我想这很好。

然后,疯狂的事情发生了。eval对by的调用Evaluator解析为它的基类,因此Point (2)被执行。此时,调试器显示elemhas ,在将其通过值传递给之前i_ = 499999不再i用于创建。下一点,这一次,它解析为has ,它超出了范围,这是调用被定向到并且断言失败时的值。elemEvaluatorBase::evalPoint (3)elemi_ = 501682Point (4)

看起来每当Element对象按值传递时,其成员的值都会更改。多次重新运行程序,会发生类似的行为,但并不总是可以重现。在实际程序中,这个类被设计成一个迭代器,它迭代一个粒子集合。虽然它迭代的东西不像容器那样精确。但无论如何,关键是它足够小,可以有效地按值传递。因此,客户端代码知道它有自己的副本Element而不是一些引用或指针,并且只要他坚持Element只提供写访问的接口,就不需要担心线程安全(太多)到整个集合的单个位置。

我用 GCC 和 Intel ICPC 尝试了相同的程序。没有什么意外发生。在实际程序中,产生正确的结果。

我是否在某处错误地使用了 OpenMP?我认为elem大约创建的Point (1)应该是循环体的本地。另外,在整个程序中,没有产生比N产生更大的价值,那么那些新的价值是从哪里来的呢?

编辑

我更仔细地查看了调试器,它显示虽然在通过值传递elem.i_时发生了更改,但指针并没有随之改变。按值传递后具有相同的值(内存地址)elemelem.src_

编辑:编译器标志

我使用 CMake 生成 MSVC 解决方案。我不得不承认我不知道如何使用 MSVC 或 Windows。我使用它的唯一原因是我知道很多人都在使用它,所以我想针对它测试我的库以解决任何问题。

CMake生成的项目,使用Visual Studio 10 Win64目标,编译器标志似乎是 /DWIN32 /D_WINDOWS /W3 /Zm1000 /EHsc /GR /D_DEBUG /MDd /Zi /Ob0 /Od /RTC1这里是在Property Pages-C/C++-Command Line中找到的命令行 /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 的好处。这表明调用链下游的某些事情是以不安全的方式执行的。我检查了汇编代码,但找不到确切的原因。我真的很困惑,因为 MSVCElement使用简单的按位复制(甚至是内联)实现了类的隐式复制构造函数,并且所有操作都在堆栈上完成。

这让我想起了这样一个事实:Sun(现在是 Oracle)的编译器坚持认为,如果启用 OpenMP 支持,它应该提高优化级别。不幸的是/openmp,MSDN 中的选项文档没有说明可能来自“错误”优化级别的可能相互引用。这也可能是一个错误。如果可以访问,我应该使用另一个版本的 VS 进行测试。

编辑:我按照承诺深入挖掘并在 Intel Parallel Inspector 2011 中运行代码。它按预期发现了一种数据竞争模式。显然当这一行被执行时:

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

根据 Windows x64 ABI 的要求,创建一个临时副本elem并按地址传递给该eval()方法。奇怪的事情来了:这个临时副本的位置并不像人们期望的那样在实现并行区域(MSVC 编译器顺便调用它)的 funclet 的堆栈上,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使用共享变量。这可以通过查看传递给的地址来轻松验证——它位于 的地址和Base::eval()的地址之间,即在共享变量块中。对程序集源的进一步检查表明,临时位置的地址确实作为参数传递给实现 OpenMP 分叉/连接模型的分叉部分的函数。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 回答