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

Почему размещение намного быстрее, чем прямое задание?

Недавно я выяснил, что использование нового места размещения быстрее, чем выполнение 16 заданий:
Рассмотрим следующий фрагмент кода (С++ 11):

class Matrix
{
public:
    double data[16];

    Matrix() : data{ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }
    {
    };

    void Identity1()
    {
        new (this) Matrix();
    };

    void Identity2()
    {
        data[0]  = 1.0; data[1]  = 0.0; data[2]  = 0.0; data[3]  = 0.0;
        data[4]  = 0.0; data[5]  = 1.0; data[6]  = 0.0; data[7]  = 0.0;
        data[8]  = 0.0; data[9]  = 0.0; data[10] = 1.0; data[11] = 0.0;
        data[12] = 0.0; data[13] = 0.0; data[14] = 0.0; data[15] = 1.0;
    };
};

Использование:

Matrix m;
//modify m.data

m.Identity1(); //~25 times faster
m.Identity2();

На моей машине Identity1() примерно в 25 раз быстрее, чем вторая функция. И теперь им любопытно, почему существует такая большая разница?

Я также пробовал третий:

void Identity3()
{
    memset(data, 0, sizeof(double) * 16);
    data[0] = 1.0;
    data[5] = 1.0;
    data[10] = 1.0;
    data[15] = 1.0;
};

Но это еще медленнее, чем Identity2(), и я не могу себе представить, почему.


Информация о профилировании

Я провел несколько профилирования, чтобы проверить, не связана ли проблема с профилированием, поэтому есть тест по умолчанию для цикла, а также внешние тесты профилирования:

Метод профилирования 1: (известный для теста цикла)

struct timespec ts1;
struct timespec ts2;

clock_gettime(CLOCK_MONOTONIC, &ts1);

for (volatile int i = 0; i < 10000000; i++)
    m.Identity(); //use 1 or 2 here

clock_gettime(CLOCK_MONOTONIC, &ts2);

int64_t start = (int64_t)ts1.tv_sec * 1000000000 + (int64_t)ts1.tv_nsec;
int64_t elapsed = ((int64_t)ts2.tv_sec * 1000000000 + (int64_t)ts2.tv_nsec) - start;

if (elapsed < 0)
    elapsed += (int64_t)0x100000 * 1000000000;

printf("elapsed nanos: %ld\n", elapsed);

Способ 2:

$ valgrind --tool=callgrind ./testcase

$ # for better overview:
$ python2 gprof2dot.py -f callgrind.out.22028 -e 0.0 -n 0.0 | dot -Tpng -o tree.png

Информация о сборке

Как пользователь T.C. изложенные в комментариях, это может быть полезно:

http://goo.gl/LC0RdG


Компиляция и информация о машине

Скомпилирован с помощью: g++ --std=c++11 -O3 -g -pg -Wall

-pg не проблема. Получилась та же разница во времени в методе измерения 1 без использования этого флага.

Machine info (lscpu):

Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                8
On-line CPU(s) list:   0-7
Thread(s) per core:    2
Core(s) per socket:    4
Socket(s):             1
NUMA node(s):          1
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 58
Model name:            Intel(R) Core(TM) i7-3612QM CPU @ 2.10GHz
Stepping:              9
CPU MHz:               2889.878
CPU max MHz:           3100.0000
CPU min MHz:           1200.0000
BogoMIPS:              4192.97
Virtualization:        VT-x
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              6144K
NUMA node0 CPU(s):     0-7
4b9b3361

Ответ 1

Независимо от того, какая была разница в 25 раз, это фактически не разница между двумя реализациями Identity().

С вашим временным кодом обе версии скомпилируются точно так же asm: пустой цикл. В коде, который вы отправили, никогда не используется m, поэтому он оптимизируется. Все, что происходит, это нагрузки/запасы счетчика циклов. (Это происходит из-за того, что вы использовали volatile int, чтобы сообщить gcc, что переменная хранится в пространстве ввода-вывода с отображением памяти, поэтому все чтения/записи из него, появляющиеся в источнике, должны фактически отображаться в asm. MSVC имеет другое значение для ключевого слова volatile, которое выходит за рамки того, что говорит стандарт.)

Посмотрите в asm on godbolt. Вот ваш код, и asm он превращается в:

for (volatile int i = 0; i < 10000000; i++)
    m.Identity1();
// same output for gcc 4.8.2 through gcc 5.2.0, with -O3

# some setup before this loop:  mov $0, 8(%rsp)  then test if it reads back as 0
.L16:
    movl    8(%rsp), %eax
    addl    $1, %eax
    movl    %eax, 8(%rsp)
    movl    8(%rsp), %eax
    cmpl    $9999999, %eax
    jle .L16

  for (volatile int i = 0; i < 10000000; i++)
    m.Identity2();

# some setup before this loop:  mov $0, 12(%rsp)  then test if it reads back as 0
.L15:
    movl    12(%rsp), %eax
    addl    $1, %eax
    movl    %eax, 12(%rsp)
    movl    12(%rsp), %eax
    cmpl    $9999999, %eax
    jle .L15

Как вы можете видеть, ни одна из них не вызывает любую версию функции Identity().

Интересно видеть в asm для Identity1, что для назначения нулей используется целое число movq, а Identity2 использует только скалярные перемещения FP. Это может иметь какое-то отношение к использованию 0.0 против 0, или это может быть связано с наложением new против простого назначения.

Я вижу в любом случае, gcc 5.2.0 не векторизовать функции Identity, если вы не используете -march=native. (В этом случае он использует загрузочные/хранилища AVX 32B для копирования из 4x32B данных. Ничего умного, как переключение байтов в регистры для перемещения 1.0 в другое место:/)

Если gcc был более умным, он сделал бы 16B-хранилище из двух нулей и сделал бы это вместо двух movsd. Может быть, он предполагает негласное, а недостаток для раскладки в кешлинке или расклассифицированной строки в неустановленном хранилище намного хуже, чем потенциал сохранения хранилища, если он выровнен.


Итак, независимо от того, что вы приурочили к этому коду, это были не ваши функции. Если один из них не сделал Identity, а другой - нет. В любом случае, потеряйте volatile от вашего счетчика циклов, что совершенно глупо. Просто посмотрите на дополнительные загрузки/запасы в пустых циклах из-за этого.

Ответ 2

Бьюсь об заклад, вы получаете такую ​​же производительность, если вы memcopy массив const-expr вручную:

static constexpr double identity_data[16] = { 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 };

void Identity3()
{
    std::copy(std::begin(identity_data), std::end(identity_data), data);
}

Ответ 3

Заинтригованный вопросом, я нашел очень хорошее сообщение в блоге по инструкциям SSE, обсуждая производительность movq и movsd здесь:

http://www.gamedev.net/blog/615/entry-2250281-demystifying-sse-move-instructions/

Так как второй набор команд [ movsd/movsq] не выполняет нулевого расширения, вы могут подумать, что они будут немного быстрее, чем те, которые выполните дополнительное заполнение нулей [movd/movq]. Однако эти инструкции могут ввести ложную зависимость от предыдущих инструкций, поскольку процессор не знает, планируете ли вы использовать дополнительные данные, которые вы не закончил стирание. Во время исполнения вне очереди это может вызвать киосков в конвейере, в то время как инструкция перемещения ждет каких-либо предыдущие инструкции, которые должны записываться в этот регистр. Если вы на самом деле не нуждалась в этой зависимости, вы излишне ввели замедление в вашем приложении.

Таким образом, более сложное декодирование команд играет мяч с конвейером, где другие инструкции должны принимать зависимости. Само декодирование, вероятно, так же быстро.

Попробовав несколько вещей на странице assembly, я также был поражен тем, как плохой простой memset переводится в встроенную сборку, когда все, что я ожидал, было простой rep stosq или развернутой версией этого.