我有一个相当复杂的程序,在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
等。但Element
,Base
,Evaluator
和Implementation
会捕获真实程序的基本结构。在调试模式下构建并运行调试器时,断言在Point (4)
处失败。
通过查看调用堆栈,
以下是调试信息的更多细节在输入Point (1)
后,本地i
的值为371152
,这很好。变量elem
未显示在框架中,这有点奇怪。但由于Point (1)
处的断言不会失效,我想这很好。
然后,疯狂的事情发生了。 eval
对Evaluator
的调用解析为其基类,因此Point (2)
已被解除。此时,debugers显示elem
有i_ = 499999
,不再是i
用来在elem
中创建Evaluator
之前传递<{1}} >按值到Base::eval
。下一点,它会解析为Point (3)
,这次,elem
有i_ = 501682
,超出范围,这是将呼叫定向到Point (4)
时的值并没有断言。
看起来每当Element
对象按值传递时,其成员的值都会更改。多次重新运行程序,虽然并不总是可重现,但会发生类似的行为。在真正的程序中,这个类被设计成像迭代器一样,迭代一组粒子。虽然它迭代的东西不像容器那样exaclty。但无论如何,重点是它足够小,可以通过价值有效地传递。因此,客户端代码知道它有自己的Element
副本而不是某些引用或指针,并且只要他坚持{{1}就不需要担心线程安全(多) }的接口,它只提供对整个集合的单个位置的写访问权。
我尝试了与GCC和英特尔ICPC相同的程序。没有任何不期待的事情发生。在真实的程序中,正确的结果产生。
我在某处错误地使用过OpenMP吗?我认为在Element
处创建的elem
应该是循环体的局部。另外,在整个程序中,没有产生大于Point (1)
的值,那么这些新值来自何处呢?
修改
我更仔细地查看了调试器,它显示当N
通过值传递时elem.i_
被更改,指针elem
不会随之改变。它通过值
编辑:编译器标记
我使用CMake生成MSVC解决方案。我必须承认我一般不知道如何使用MSVC或Windows。我使用它的唯一原因是我知道很多人都使用它,所以我想测试我的库以解决任何问题。
CMake生成的项目,使用elem.src_
目标,编译器标志似乎是
Visual Studio 10 Win64
这是命令行在Property Pages-C / C ++ - 命令行中找到的
/DWIN32 /D_WINDOWS /W3 /Zm1000 /EHsc /GR /D_DEBUG /MDd /Zi /Ob0 /Od /RTC1
这里有什么可疑的吗?
答案 0 :(得分:8)
显然,MSVC中的64位OpenMP实现与代码不兼容,没有优化编译。
为了调试您的问题,我修改了您的代码,以便在调用threadprivate
之前将迭代编号保存到this->eval()
全局变量,然后在{{1}的开头添加一个检查查看保存的迭代编号是否与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 *))
的值变得足够大才能触发断言时才会看到它。有时,混合值不会超过容器的大小,然后代码在没有断言的情况下完成执行。在断言之后的调试会话中你看到的是VS调试器无法正确处理多线程代码:)
这只发生在未经优化的64位模式中。它不会发生在32位代码中(包括调试和发布)。除非禁用优化,否则64位版本代码中也不会发生这种情况。如果将elem.i_
的呼叫置于关键部分,也不会发生这种情况:
this->eval()
但这样做会取消OpenMP的好处。这表明调用链中的某些内容以不安全的方式执行。我检查了汇编代码,但找不到确切的原因。我真的很困惑,因为MSVC使用简单的按位复制(甚至内联)实现#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);
}
}
类的隐式复制构造函数,并且所有操作都在堆栈上完成。
这让我想起Sun的(现在的Oracle)编译器坚持认为如果能够支持OpenMP,它应该提高优化级别。不幸的是,MSDN中Element
选项的文档没有说明可能来自“错误”优化级别的可能干扰。这也可能是一个错误。我应该测试另一个版本的VS,如果我可以访问它。
编辑我按照承诺深入挖掘并运行英特尔Parallel Inspector 2011中的代码。它找到了一个预期的数据竞争模式。显然,当执行此行时:
/openmp
根据Windows x64 ABI的要求,创建this->eval(dim, elem, r);
的临时副本并按地址传递给elem
方法。这就是奇怪的事情:这个临时副本的位置不在实现并行区域的funclet的堆栈上(顺便说一下MSVC编译器将它eval()
调用),正如人们所期望的那样,而是它的地址被采用作为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()
的地址和N
的地址之间,即在共享变量块中。对汇编源的进一步检查表明,临时场所的地址确实作为参数传递给实现OpenMP fork / join模型的fork部分的src
_vcomp_fork()
函数。
由于除了启用优化之外,基本上没有可以影响此行为的编译器选项导致vcomp100.dll
,Base::eval()
和Base::eval_dispatch()
全部内联,因此没有临时副本{ {1}}永远都是,我找到的唯一解决办法是:
1)将Implementation::eval()
参数设为elem
参考:
Element elem
这确保传递实现Base::eval()
中并行区域的funclet堆栈中的void eval (int dim, Element& elem, double *res)
{
eval_dispatch(dim, elem, res, &Derived::eval); // Point (2)
}
的本地副本,而不是共享临时副本。这将作为另一个临时副本进一步传递给elem
,但它保留了正确的值,因为这个新的临时副本位于Evaluator<Implementation>::operator()
的堆栈中而不是共享变量块中。
2)向Base::eval_dispatch()
提供明确的复制构造函数:
Base::eval()
我建议您使用显式复制构造函数,因为它不需要在源代码中进一步更改。
显然,这种行为也存在于MSVS 2008中。我必须检查它是否也存在于MSVS 2012中,并可能向MS提交错误报告。
这个错误没有在32位代码中显示,因为值对象传递的每个值的整个值都被推送到调用堆栈而不仅仅是指向它的指针。