Почему моя программа такая медленная?

Кто-то решил сделать быстрый тест, чтобы увидеть, как нативный клиент сравнивается с javascript с точки зрения скорости. Они сделали это, выполнив 10 000 000 квадратных расчетов и измерив время, которое потребовалось. Результат с javascript: 0.096 секунд, и с NaCl: 4.241 секунд... Как это может быть? Разве скорость не является одной из причин использования NaCl в первую очередь? Или я пропускаю некоторые флаги компилятора или что-то?

Вот код, который был запущен:

clock_t t = clock();
float result = 0;
for(int i = 0; i < 10000000; ++i) {
    result += sqrt(i);
}
t = clock() - t;      
float tt = ((float)t)/CLOCKS_PER_SEC;
pp::Var var_reply = pp::Var(tt);
PostMessage(var_reply);

PS: Этот вопрос является отредактированной версией чего-то, что появилось в списке рассылки родного клиента

1 ответ

ПРИМЕЧАНИЕ. Этот ответ является отредактированной версией того, что появилось в списке рассылки собственного клиента.

Микробенчмарки хитрые: если вы не понимаете, что делаете ОЧЕНЬ хорошо, легко провести сравнения яблок с апельсинами, которые не имеют отношения к поведению, которое вы хотите наблюдать / измерить вообще.

Я немного уточню на вашем собственном примере (исключу NaCl и придерживаюсь существующих, "проверенных и настоящих" технологий).

Вот ваш тест в качестве родной программы на C:

$ cat test1.c
#include <math.h>
#include <time.h>
#include <stdio.h>

int main() {
  clock_t t = clock();
  float result = 0;
  for(int i = 0; i < 1000000000; ++i) {
      result += sqrt(i);
  }
  t = clock() - t;
  float tt = ((float)t)/CLOCKS_PER_SEC;
  printf("%g %g\n", result, tt);

}
$ gcc -std=c99 -O2 test1.c -lm -o test1
$ ./test1
5.49756e+11 25.43

Хорошо. Мы можем сделать миллиард циклов за 25,43 секунды. Но давайте посмотрим, что занимает время: заменим "result += sqrt(i);" с "результатом + = я;"

$ cat test2.c
#include <math.h>
#include <time.h>
#include <stdio.h>

int main() {
  clock_t t = clock();
  float result = 0;
  for(int i = 0; i < 1000000000; ++i) {
      result += i;
  }
  t = clock() - t;
  float tt = ((float)t)/CLOCKS_PER_SEC;
  printf("%g %g\n", result, tt);
}
$ gcc -std=c99 -O2 test2.c -lm -o test2
$ ./test2
1.80144e+16 1.21

Вот Это Да! Фактически 95% времени было потрачено на функцию sqrt, обеспечиваемую процессором, на все остальное ушло менее 5%. Но что, если мы немного изменим код: замените "printf("%g %g\n", result, tt);" с "printf("%g\n", tt);"?

$ cat test3.c
#include <math.h>
#include <time.h>
#include <stdio.h>

int main() {
  clock_t t = clock();
  float result = 0;
  for(int i = 0; i < 1000000000; ++i) {
      result += sqrt(i);
  }
  t = clock() - t;
  float tt = ((float)t)/CLOCKS_PER_SEC;
  printf("%g\n", tt);
}
$ gcc -std=c99 -O2 test3.c -lm -o test3
$ ./test
1.44

Хм... Похоже, теперь "sqrt" почти так же быстро, как "+". Как это может быть? Как printf может повлиять на предыдущий цикл ВСЕ?

Посмотрим:

$ gcc -std=c99 -O2 test1.c -S -o -
...
.L3:
        cvtsi2sd        %ebp, %xmm1
        sqrtsd  %xmm1, %xmm0
        ucomisd %xmm0, %xmm0
        jp      .L7
        je      .L2
.L7:
        movapd  %xmm1, %xmm0
        movss   %xmm2, (%rsp)
        call    sqrt
        movss   (%rsp), %xmm2
.L2:
        unpcklps        %xmm2, %xmm2
        addl    $1, %ebp
        cmpl    $1000000000, %ebp
        cvtps2pd        %xmm2, %xmm2
        addsd   %xmm0, %xmm2
        unpcklpd        %xmm2, %xmm2
        cvtpd2ps        %xmm2, %xmm2
        jne     .L3
 ...
$ gcc -std=c99 -O2 test3.c -S -o -
...
        xorpd   %xmm1, %xmm1
...
.L5:
        cvtsi2sd        %ebp, %xmm0
        ucomisd %xmm0, %xmm1
        ja      .L14
.L10:
        addl    $1, %ebp
        cmpl    $1000000000, %ebp
        jne     .L5
...
.L14:
        sqrtsd  %xmm0, %xmm2
        ucomisd %xmm2, %xmm2
        jp      .L12
        .p2align 4,,2
        je      .L10
.L12:
        movsd   %xmm1, (%rsp)
        .p2align 4,,5
        call    sqrt
        movsd   (%rsp), %xmm1
        .p2align 4,,4
        jmp     .L10
...

Первая версия на самом деле вызывает sqrt миллиард раз, а вторая вообще не делает этого! Вместо этого он проверяет, является ли число отрицательным, и вызывает sqrt только в этом случае! Зачем? Что пытается сделать компилятор (или, скорее, авторы компилятора) здесь?

Ну, это просто: так как мы не использовали "result" в этой конкретной версии, он может спокойно пропустить вызов "sqrt"... если значение не отрицательное, то есть! Если он отрицательный, то (в зависимости от флагов FPU) sqrt может делать разные вещи (возвращать бессмысленные результаты, аварийно завершать работу программы и т. Д.). Вот почему эта версия в десятки раз быстрее, но она вообще не рассчитывает квадратные корни!

Вот последний пример, который показывает, как могут идти неправильные микробенчмарки:

$ cat test4.c
#include <math.h>
#include <time.h>
#include <stdio.h>

int main() {
  clock_t t = clock();
  int result = 0;
  for(int i = 0; i < 1000000000; ++i) {
      result += 2;
  }
  t = clock() - t;
  float tt = ((float)t)/CLOCKS_PER_SEC;
  printf("%d %g\n", result, tt);
}
$ gcc -std=c99 -O2 test4.c -lm -o test4
$ ./test4
2000000000 0

Время выполнения... НОЛЬ? Как это может быть? Миллиарды вычислений за мгновение ока? Посмотрим:

$ gcc -std=c99 -O2 test1.c -S -o -
...
        call    clock
        movq    %rax, %rbx
        call    clock
        subq    %rbx, %rax
        movl    $2000000000, %edx
        movl    $.LC1, %esi
        cvtsi2ssq       %rax, %xmm0
        movl    $1, %edi
        movl    $1, %eax
        divss   .LC0(%rip), %xmm0
        unpcklps        %xmm0, %xmm0
        cvtps2pd        %xmm0, %xmm0
...

О, цикл полностью исключен! Все вычисления происходили во время компиляции, и, чтобы добавить оскорбление к травме, оба "тактовых" вызова были выполнены до загрузки тела цикла!

Что если мы поместим это в отдельную функцию?

$ cat test5.c
#include <math.h>
#include <time.h>
#include <stdio.h>

int testfunc(int num, int max) {
  int result = 0;
  for(int i = 0; i < max; ++i) {
      result += num;
  }
  return result;
}

int main() {
  clock_t t = clock();
  int result = testfunc(2, 1000000000);
  t = clock() - t;
  float tt = ((float)t)/CLOCKS_PER_SEC;
  printf("%d %g\n", result, tt);
}
$ gcc -std=c99 -O2 test5.c -lm -o test5
$ ./test5
2000000000 0

Все тот же??? Как это может быть?

$ gcc -std=c99 -O2 test5.c -S -o -
...
.globl testfunc
        .type   testfunc, @function
testfunc:
.LFB16:
        .cfi_startproc
        xorl    %eax, %eax
        testl   %esi, %esi
        jle     .L3
        movl    %esi, %eax
        imull   %edi, %eax
.L3:
        rep
        ret
        .cfi_endproc
...

Э-э-э: компилятор достаточно умен, чтобы заменить цикл на умножение!

Теперь, если вы добавите NaCl с одной стороны и JavaScript с другой стороны, вы получите такую ​​сложную систему, что результаты в буквальном смысле непредсказуемы.

Проблема здесь в том, что для микробенчмарка вы пытаетесь изолировать кусок кода и затем оценивать его свойства, но затем компилятор (независимо от JIT или AOT) попытается помешать вашим усилиям, потому что он пытается удалить все бесполезные вычисления из вашей программы!

Микробенчмарки полезны, конечно, но это инструмент FORENSIC ANALYSIS, а не то, что вы хотите использовать для сравнения скорости двух разных систем! Для этого вам нужна некоторая "реальная" (в некотором смысле мир: нечто, что не может быть оптимизировано на куски перегруженным компилятором) рабочей нагрузкой: в частности, популярны алгоритмы сортировки.

Тесты, которые используют sqrt, особенно неприятны, потому что, как мы видели, обычно они проводят более 90% времени при выполнении одной инструкции CPU: sqrtsd (fsqrt, если это 32-битная версия), что, конечно, идентично для JavaScript и NaCl, Эти тесты (если они правильно реализованы) могут служить лакмусовой бумажкой (если скорость какой-либо реализации слишком сильно отличается от того, что демонстрирует простая нативная версия, значит, вы делаете что-то не так), но они бесполезны для сравнения скоростей NaCl, JavaScript, C# или Visual Basic.

Другие вопросы по тегам