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

Кто-то решил сделать быстрый тест, чтобы узнать, как родной клиент по сравнению с javascript с точки зрения скорости. Они сделали это, выполнив 10 000 000 sqrt расчетов и измерив время, которое потребовалось. Результат с 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

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

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

Я немного опишу ваш собственный пример (я исключу 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

Ok. Мы можем сделать миллиард циклов за 25,43 секунды. Но давайте посмотрим, что требует времени: замените "result + = sqrt (i)"; с "результатом + = 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% времени было фактически потрачено на CPU-предоставленную функцию 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 влияет на предыдущий цикл AT ALL?

Посмотрим:

$ 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 только в этом случае! Зачем? Что здесь пытаются сделать компилятор (или, скорее, авторы компилятора)?

Ну, это просто: поскольку мы не использовали "результат" в этой конкретной версии, он может спокойно опустить вызов "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

Время выполнения... ZERO? Как это может быть? Миллион вычислений меньше, чем мгновение глаз? Давайте посмотрим:

$ 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
...

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

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

$ 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
...

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

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

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

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

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