Почему моя программа такая медленная?
Кто-то решил сделать быстрый тест, чтобы увидеть, как нативный клиент сравнивается с 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.