OpenMP с MSVC 2010 Debug строит странную ошибку при копировании объекта

У меня довольно сложная программа, которая запускается в странное поведение при сборке с помощью OpenMP в режиме отладки MSVC 2010. Я попытался изо всех сил построить следующий минимальный рабочий пример (хотя он и не очень минимальный), который минимизирует структуру реальной программы.

#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 захватывает основную структуру реальной программы. Когда вы создаете режим Debug и запускаете отладчик, утверждение терпит неудачу при Point (4).

Ниже приведена подробная информация об отладочной информации, просмотрев стопки вызовов,

При входе Point (1) локальный i имеет значение 371152, что отлично. Переменная elem не отображается в кадре, что немного странно. Но так как утверждение в Point (1) не исчезает, я думаю, это прекрасно.

Затем произошли сумасшедшие вещи. Вызов eval на Evaluator вызывает его базовый класс, и поэтому Point (2) был эксплицирован. На этом этапе отладчики показывают, что elem имеет i_ = 499999, который больше не является i, используемым для создания elem в Evaluator, прежде чем передать его по значению на Base::eval. Следующий момент, он решает Point (3), на этот раз elem имеет i_ = 501682, который выходит за пределы диапазона, и это значение, когда вызов направлен на Point (4) и не удалось выполнить утверждение.

Похоже, всякий раз, когда объект Element передается по значению, значение его членов изменяется. Повторно запускайте программу несколько раз, подобное поведение происходит, хотя и не всегда воспроизводимое. В реальной программе этот класс предназначен как итератор, который перебирает коллекцию частиц. Хотя то, что он повторяет, не является чем-то вроде контейнера. Но в любом случае, дело в том, что оно достаточно мало, чтобы быть эффективно переданным по значению. И поэтому код клиента знает, что у него есть своя копия Element вместо некоторой ссылки или указателя, и не нужно беспокоиться о потокобезопасности (много), если он придерживается интерфейса Element, который только обеспечивают доступ на запись к одной позиции всей коллекции.

Я попробовал ту же программу с GCC и Intel ICPC. Ничего непредвиденного не происходит. И в реальной программе правильные результаты при их создании.

Я неправильно использовал OpenMP? Я думал, что созданный elem около Point (1) должен быть локальным для тела цикла. Кроме того, во всей программе не было создано значения больше N, так где же происходит новое значение?

Edit

Я посмотрел более осторожно в отладчик, он показывает, что в то время как elem.i_ был изменен, когда elem был передан по значению, указатель elem.src_ не изменится вместе с ним. Он имеет то же значение (адрес памяти) после передачи значением

Изменить: флаги компилятора

Я использовал CMake для создания решения MSVC. Должен признаться, я понятия не имею, как использовать MSVC или Windows в целом. Единственная причина, по которой я его использую, - это то, что я знаю, что многие люди используют его, поэтому я хочу протестировать свою библиотеку против него, чтобы решить любые проблемы.

Проект сгенерированный CMake с использованием Visual Studio 10 Win64 target, флаги компилятора /DWIN32 /D_WINDOWS /W3 /Zm1000 /EHsc /GR /D_DEBUG /MDd /Zi /Ob0 /Od /RTC1 И вот командная строка, найденная в Property Pages-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

Есть ли что-то подозрительное здесь?

Ответ 1

По-видимому, 64-битная реализация OpenMP в MSVC несовместима с кодом, скомпилированным без оптимизаций.

Чтобы отладить вашу проблему, я изменил свой код, чтобы сохранить номер итерации в глобальной переменной 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. Это показывает, что что-то дальше по цепочке вызовов выполняется небезопасным способом. Я изучил код сборки, но не смог найти точную причину. Я действительно озадачен, так как MSVC реализует неявный конструктор копирования класса Element, используя простую побитовую копию (она даже встроенная), и все операции выполняются в стеке.

Это напоминает мне о том, что компилятор Sun (теперь Oracle) настаивает на том, что он должен повысить уровень оптимизации, если вы поддерживаете поддержку OpenMP. К сожалению, документация опции /openmp в MSDN ничего не говорит о возможном вмешательстве, которое может исходить из "неправильного" уровня оптимизации. Это также может быть ошибкой. Я должен проверить с другой версией VS, если я могу получить доступ к ней.

Изменить: Я вырыл глубже, как было обещано, и запустил код в Intel Parallel Inspector 2011. Он обнаружил один шаблон расы данных, как и ожидалось. По-видимому, когда эта строка выполняется:

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

создается временная копия elem и передается по адресу методу eval(), как это требуется в ABI для Windows x64. И вот странная вещь: расположение этой временной копии не входит в стек funclet, который реализует параллельную область (компилятор MSVC вызывает его Evaluator$omp$1<Implementation>::operator() кстати), как и следовало ожидать, но скорее его адрес берется как первый аргумент funclet. Поскольку этот аргумент один и тот же во всех потоках, это означает, что временная копия, которая далее передается в 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 и адресом src, то есть в блоке с разделяемыми переменными. Дальнейшая проверка источника сборки показывает, что действительно адрес временного места передается как аргумент функции _vcomp_fork() из vcomp100.dll, которая реализует часть fork модели OpenMP fork/join.

Поскольку в принципе нет параметров компилятора, которые могут влиять на это поведение, кроме возможности оптимизаций, которые приводят к тому, что все 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-битном коде, так как все значение, переданное объектом value, помещается в стек вызовов, а не только указатель на него.