Две очень похожие функции, связанные с sin(), демонстрируют значительно отличающуюся производительность - почему?

Рассмотрим следующие две программы, которые выполняют одни и те же вычисления двумя способами:

// v1.c
#include <stdio.h>
#include <math.h>
int main(void) {
   int i, j;
   int nbr_values = 8192;
   int n_iter = 100000;
   float x;
   for (j = 0; j < nbr_values; j++) {
      x = 1;
      for (i = 0; i < n_iter; i++)
         x = sin(x);
   }
   printf("%f\n", x);
   return 0;
}

и

// v2.c
#include <stdio.h>
#include <math.h>
int main(void) {
   int i, j;
   int nbr_values = 8192;
   int n_iter = 100000;
   float x[nbr_values];
   for (i = 0; i < nbr_values; ++i) {
      x[i] = 1;
   }
   for (i = 0; i < n_iter; i++) {
      for (j = 0; j < nbr_values; ++j) {
         x[j] = sin(x[j]);
      }
   }
   printf("%f\n", x[0]);
   return 0;
}

Когда я компилирую их с помощью gcc 4.7.2 с -O3 -ffast-math и запускаю в окне Sandy Bridge, вторая программа в два раза быстрее первой.

Почему это?

Один из подозреваемых - это зависимость данных между последовательными итерациями цикла i в v1. Однако я не совсем понимаю, что может быть полным объяснением.

(Вопрос, вдохновленный Почему мой пример python/numpy быстрее, чем чистая реализация C?)

EDIT:

Вот сгенерированная сборка для v1:

        movl    $8192, %ebp
        pushq   %rbx
LCFI1:
        subq    $8, %rsp
LCFI2:
        .align 4
L2:
        movl    $100000, %ebx
        movss   LC0(%rip), %xmm0
        jmp     L5
        .align 4
L3:
        call    _sinf
L5:
        subl    $1, %ebx
        jne     L3
        subl    $1, %ebp
        .p2align 4,,2
        jne     L2

и для v2:

        movl    $100000, %r14d
        .align 4
L8:
        xorl    %ebx, %ebx
        .align 4
L9:
        movss   (%r12,%rbx), %xmm0
        call    _sinf
        movss   %xmm0, (%r12,%rbx)
        addq    $4, %rbx
        cmpq    $32768, %rbx
        jne     L9
        subl    $1, %r14d
        jne     L8

Ответ 1

Игнорировать структуру цикла все вместе и думать только о последовательности вызовов sin. v1 выполняет следующие действия:

x <-- sin(x)
x <-- sin(x)
x <-- sin(x)
...

то есть, каждое вычисление sin( ) не может начинаться до тех пор, пока не будет доступен результат предыдущего вызова; он должен дождаться полного завершения предыдущего вычисления. Это означает, что для N вызовов sin общее требуемое время составляет 819200000 раз за латентность одной оценки sin.

В v2, напротив, вы делаете следующее:

x[0] <-- sin(x[0])
x[1] <-- sin(x[1])
x[2] <-- sin(x[2])
...

обратите внимание, что каждый вызов sin не зависит от предыдущего вызова. Фактически, вызовы sin являются независимыми, и процессор может начинаться с каждого, как только будут доступны необходимые ресурсы регистров и ALU (без ожидания завершения предыдущего вычисления). Таким образом, требуемое время является функцией пропускной способности функции sin, а не задержки, и поэтому v2 может закончиться за значительно меньшее время.


Я также должен отметить, что DeadMG прав, что v1 и v2 формально эквивалентны, и в идеальном мире компилятор оптимизирует оба из них в одну цепочку из оценок 100000 sin (или просто оценивает результат во время компиляции). К сожалению, мы живем в несовершенном мире.

Ответ 2

В первом примере он запускает 100000 циклов sin, 8192 раз.

Во втором примере он запускает 8192 цикла sin, 100000 раз.

Помимо этого и сохранения результата по-разному, я не вижу никакой разницы.

Однако имеет значение то, что ввод изменяется для каждого цикла во втором случае. Поэтому я подозреваю, что происходит то, что значение sin в определенные моменты цикла становится намного проще вычислять. И это может иметь большое значение. Вычисление греха не является полностью тривиальным, и это серия вычислений, которая петляет до тех пор, пока не будет достигнуто условие выхода.