Подтвердить что ты не робот

Почему компилятор загружает этот указатель из памяти в цикле

Я пытаюсь определить, какие служебные данные std::atomic вводят в безусловную запись памяти в мою систему (восьмиядерный x64). Вот моя тестовая программа:

#include <atomic>
#include <iostream>
#include <omp.h>

int main() {
    std::atomic_int foo(0); // VERSION 1
    //volatile int foo = 0; // VERSION 2

    #pragma omp parallel
    for (unsigned int i = 0; i < 10000000; ++i) {
        foo.store(i, std::memory_order_relaxed); // VERSION 1
        //foo = i; // VERSION 2
    }

    std::cout << foo << std::endl;
}

Программа as-is проведет тестирование std::atomic_int и комментирует строки с меткой VERSION 1 и раскомментирует строки с меткой VERSION 2 проверит volatile int на своем месте. Даже при несинхронизированном выводе обеих программ должно быть 10000000 - 1.

Это моя команда:

g++ -O2 -std=c++11 -fopenmp test.c++

Версия, использующая atomic_int, занимает от двух до трех секунд в моей системе, а та, которая использует volatile int, почти всегда завершается менее чем за десятую часть секунды.

Значительная разница в сборке - это (вывод из diff --side-by-side):

volatile int                        atomic_int
.L2:                                .L2:
    mov DWORD PTR [rdi], eax          | mov rdx, QWORD PTR [rdi]
                                      > mov DWORD PTR [rdx], eax
    add eax, 1                          add eax, 1
    cmp eax, 10000000                   cmp eax, 10000000
    jne .L2                             jne .L2
    rep ret                             rep ret

rdi - это первый аргумент этой функции, который запускается параллельно (он нигде не изменяется в функции), и он, по-видимому, является указателем на (указатель на, во втором столбце) целое число foo. Я не считаю, что этот дополнительный mov является неотъемлемой частью гарантии атомарности atomic_int.

Дополнительный mov действительно является источником замедления для atomic_int; перемещение его выше L2 позволяет обеим версиям достичь одинаковой производительности и выводит правильный номер.

Когда foo создается глобальная переменная, atomic_int достигает такой же повышенной производительности volatile int.

Мои вопросы таковы: почему компилятор передает указатель на указатель в случае выделенного стеком atomic_int, но только указатель в случае глобального atomic_int или выделенного в стеке volatile int; почему он загружает этот указатель на каждую итерацию цикла, поскольку он (я считаю) цикл-инвариантный код; и какие изменения в источнике С++ я могу сделать, чтобы atomic_int соответствовать volatile int в этом тесте?

Update

Запуск этой программы:

#include <atomic>
#include <iostream>
#include <thread>

//using T = volatile int; // VERSION 1
using T = std::atomic_int; // VERSION 2

void foo(T* ptr) {
    for (unsigned int i = 0; i < 10000000; ++i) {
        //*ptr = i; // VERSION 1
        ptr->store(i, std::memory_order_relaxed); // VERSION2
    }
}

int main() {
    T i { 0 };

    std::thread threads[4];

    for (auto& x : threads)
        x = std::move(std::thread { foo, &i });

    for (auto& x : threads)
        x.join();

    std::cout << i << std::endl;
}

дает ту же самую улучшенную производительность для обеих версий 1 и 2, что заставляет меня думать, что это особенность OpenMP, которая заставляет худший perf для atomic_int. Правильно ли OpenMP, или он генерирует субоптимальный код?

4b9b3361

Ответ 1

Все становится понятнее, если вы посмотрите на промежуточное представление (-fdump-tree-all является вашим другом там) программы, а не на выходе сборки.

Почему компилятор передает указатель на указатель в случае выделенного стеком atomic_int, но только указатель в случае глобального atomic_int или выделенного в стеке volatile int;

Это деталь реализации. GCC преобразует параллельные области, выделяя их на отдельные функции, которые затем получают в качестве единственного аргумента структуру, содержащую все общие переменные, а также начальное значение firstprivate и заполнители для конечного значения переменных lastprivate. Когда foo является просто целым числом, и не существует явных или явных областей flush, компилятор передает копию в аргументе указанной функции:

struct omp_data_s
{
   int foo;
};

void main._omp_fn.0(struct omp_data_s *omp_data_i)
{
   ...
   omp_data_i->foo = i;
   ...
}

int main() {
  volatile int foo = 0;

  struct omp_data_s omp_data_o;
  omp_data_o.foo = foo;

  GOMP_parallel(main._omp_fn.0, &omp_data_o, 0, 0);

  foo = omp_data_o.foo;
  ...
}

omp_data_i передается через rdi (для x86-64 ABI) и omp_data_i->foo = i; компилируется просто movl %rax, %(rdi) (при условии, что i хранится в rax), так как foo является первым (и только) элемента структуры.

Когда foo есть std::atomic_int, он больше не является целым числом, а структурой, обертывающей целочисленное значение. В этом случае GCC передает указатель в структуре параметров, а не само значение:

struct omp_data_s
{
   struct atomic_int *foo;
};

void main._omp_fn.0(struct omp_data_s *omp_data_i)
{
   ...
   __atomic_store_4(&omp_data_i->foo._M_i, i, 0);
   ...
}

int main() {
  struct atomic_int foo;

  struct omp_data_s omp_data_o;
  omp_data_o.foo = &foo;

  GOMP_parallel(main._omp_fn.0, &omp_data_o, 0, 0);

  ...
}

В этом случае дополнительная инструкция по сборке (movq %(rdi), %rdx) является разыменованием первого указателя (к структуре данных OpenMP), вторая - это атомная запись (которая на x86-64 является просто хранилищем).

Когда foo является глобальным, он не передается как часть структуры аргумента в обведенный код. В этом конкретном случае код получает указатель NULL, поскольку структура аргумента пуста.

void main._omp_fn.0(void *omp_data_i)
{
   ...
   __atomic_store_4(&foo._M_i, i, 0);
   ...
}

почему он загружает этот указатель на каждую итерацию цикла, поскольку он (я считаю) цикл-инвариантный код;

Сам указатель (значение rdi) является цикловым инвариантом, но указанное значение может меняться вне функции как foo - общая переменная. Эффективно GCC обрабатывает все переменные с классом разделения данных OpenMP shared как volatile. Опять же, это детализация реализации, так как стандарт OpenMP позволяет использовать модель памяти с непринужденной последовательностью, где записи в общие переменные не становятся видимыми в других потоках, если только конструкция flush не используется как для писателя, так и для читателя. GCC фактически использует эту непринужденную последовательность для оптимизации кода, передавая копию некоторых общих переменных вместо указателей на исходные переменные (таким образом сохраняя одно разыменование). Если бы в вашем коде была бы область flush, либо явная

foo = i;
#pragma omp flush(foo)

или неявный

#pragma omp atomic write
foo = i;

GCC передал бы указатель на foo, как показано в другом ответе. Причина в том, что конструкция flush синхронизирует представление памяти потока с глобальным представлением, в котором общий foo ссылается на исходную переменную (следовательно, указатель на нее вместо копии).

и какие изменения в источнике С++ можно сделать для atomic_int соответствия volatile int в этом тесте?

Помимо переключения на другой компилятор, я не могу придумать никаких изменений переносимого. GCC передает общие переменные типа структуры (std::atomic является структурой) в качестве указателей и что он.

Правильно ли OpenMP, или он генерирует субоптимальный код?

OpenMP корректен. Это спецификация multipaform, которая определяет конкретную (и преднамеренно широкую) память и оперативную семантику, что GCC следует. Это может не всегда давать вам лучшую производительность для конкретного случая на конкретной платформе, но тогда код переносимый, и относительно легко перейти от последовательного к параллельному с добавлением одной прагмы.

Конечно, люди GCC могли бы научиться лучше оптимизировать - компилятор Intel С++ уже делает:

                            # LOE rdx ecx
..B1.14:                    # Preds ..B1.15 ..B1.13
    movl      %ecx, %eax                                #13.13
    movl      %eax, (%rdx)                              #13.13
                            # LOE rdx ecx
..B1.15:                    # Preds ..B1.14
    incl      %ecx                                      #12.46
    cmpl      $10000000, %ecx                           #12.34
    jb        ..B1.14       # Prob 99%                  #12.34

Ответ 2

Я не считаю, что это дополнительное mov является неотъемлемой частью гарантии атомарности atom_int.

OpenMP, похоже, думает иначе. Летучий код с атомарностью OpenMP:

#include <atomic>
#include <iostream>
#include <omp.h>

int main() {
    volatile int foo = 0; // VERSION 2

    #pragma omp parallel
    for (unsigned int i = 0; i < 10000000; ++i) {
        #pragma omp atomic write
        foo = i; // VERSION 2
    }
    std::cout << foo << std::endl;
}

Сборка:

.L2:
        movq    (%rdi), %rdx
        movl    %eax, (%rdx)
        addl    $1, %eax
        cmpl    $10000000, %eax
        jne     .L2
        ret