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

Объяснение: log10 быстрее, чем log и log2, но только с O2 и более

Мне нужно использовать функцию логарифма в каком-то из моего кода, но база не имеет значения. Поэтому я решил выбрать между log(), log2() и log10() по производительности, если обнаружил существенные отличия. (Я буду ссылаться на указанные функции как ln, lb и lg соответственно).

Почему я суетился об этом? Потому что я буду вызывать функцию так часто, как 400 000 000 раз за итерацию алгоритма оптимизации. Это не факультативно, ни тема моего вопроса.

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

timespec start, end;
double sum = 0, m;

clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &start);
for (int n = 1; n < INT_MAX; ++n)
{
    m = n * 10.1;
    sum += log(m);
}
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &end);

cout << "ln=";
cout << diff(start, end).tv_sec << ":" << diff(start, end).tv_nsec << endl;

... // likewise for log2 and log10

(timespec diff (timepec start, timepec end), если вы этого желаете....)

Были получены следующие результаты:

GCC v4.6.3

-O0
ln=140:516853107
lb=155:878100147
lg=173:534086352

-O1
ln=133:948317112
lb=144:78885393
lg=163:870021712

-O2
ln=9:108117039
lb=9:134447209
lg=4:87951676

-O3
ln=9:102016996
lb=9:204672042
lg=4:153153558

Я просмотрел вывод компиляции с помощью -S, но у меня действительно нет достаточного сцепления с ассемблером, чтобы полностью понять различия. -S вывод: - O0 -S, - O3 -S

Почему lg лучше оптимизируется с O2/O3?

РЕДАКТИРОВАТЬ: Исходный код, обратите внимание на опечатку в третьем цикле, это причина того, что log10 выглядит быстрее (мульти-оптимизируется), Я принял ответ, который, я считаю, ближе всего, поскольку вопрос теперь закрыт, хотя я многому научился от ответов drhirsch и janneb.

4b9b3361

Ответ 1

Я заметил некоторые вещи. Если я скомпилирую (GCC 4.5.3) ваш ассемблер, перечисляющий -O3 -S с помощью g++ logflt.S -lrt, я могу воспроизвести поведение. Мои тайминги:

ln=6:984160044
lb=6:950842852
lg=3:64288522

Затем я просмотрел вывод с помощью objdump -SC a.out. Я предпочитаю это смотреть в файлы .S, так как есть конструкции, которые я еще не понимаю. Код не очень легко читать, но я нахожу следующее:

Перед вызовом log или log2 аргумент преобразуется с помощью

400900:       f2 0f 2a c3             cvtsi2sd %ebx,%xmm0
400904:       66 0f 57 c9             xorpd  %xmm1,%xmm1
400908:       f2 0f 59 05 60 04 00    mulsd  0x460(%rip),%xmm0
40090f:       00 
400910:       66 0f 2e c8             ucomisd %xmm0,%xmm1

0x460(%rip) - относительный адрес, указывающий на шестнадцатеричное значение 0000 00000000 33333333 33332440. Это 16-байтная пара SSE double, из которой важна только одна двойная (код использует скалярный SSE). Этот двойной символ 10.1. Таким образом, mulsd выполняет умножение в строке С++ m = n * 10.1;.

log10 отличается:

400a40:       f2 0f 2a c3             cvtsi2sd %ebx,%xmm0
400a44:       66 0f 57 c9             xorpd  %xmm1,%xmm1
400a48:       66 0f 2e c8             ucomisd %xmm0,%xmm1

Я думаю, что для случая log10 вы забыли выполнить умножение! Итак, вы просто вызываете log10 с тем же значением снова и снова... Я бы не удивил меня если процессор достаточно умен, чтобы оптимизировать это.

EDIT: Я сейчас очень уверен, что это проблема, потому что в вашем другом листинге (-O0 -S) умножение выполняется правильно - поэтому отправьте свой код, и пусть другие докажут, что я ошибаюсь!

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

log(n * 10.1) = log(n) + log(10.1)

Но в этом случае log(10.1) нужно было бы вычислить один раз, и я не вижу этого кода. Я также сомневаюсь, что GCC сделает это для log10, но не для log и log2.

Ответ 2

Это будет зависеть от реализации функций log() в библиотеке C, версии компилятора, аппаратной архитектуры и т.д. В любом случае, ниже я использую GCC 4.4 на x86-64 с glibc 2.11.

Изменение примера, чтобы добавить строку

cout << "sum=" << sum << endl;

который запрещает компилятору оптимизировать вызовы log(), как я упоминал в комментарии, я получаю следующие тайминги (только целые секунды, -O2):

  • log: 98s
  • log2: 105s
  • log10: 120s

Эти тайминги кажутся примерно согласованными с таймингами -O0 и -O1 в исходном сообщении; при более высоких уровнях оптимизации оценки журналов оптимизируются, поэтому результаты -O2 и -O3 настолько различны.

Кроме того, глядя на пример журнала с профилиром "perf", топ-5 правонарушителей в отчете


# Samples: 3259205
#
# Overhead         Command              Shared Object  Symbol
# ........  ..............  .........................  ......
#
    87.96%             log  /lib/libm-2.11.1.so        [.] __ieee754_log
     5.51%             log  /lib/libm-2.11.1.so        [.] __log
     2.88%             log  ./log                      [.] main
     2.84%             log  /lib/libm-2.11.1.so        [.] __isnan
     0.69%             log  ./log                      [.] [email protected]

За исключением основного, все остальные символы связаны с вызовом log(). Подводя итог этим, мы можем заключить, что 97% общей продолжительности выполнения этого примера расходуется на log().

Реализация __ieee754_log можно найти здесь, в репозитории glibc git. Соответственно, другие реализации: log2, log10. Обратите внимание, что предыдущие ссылки относятся к версиям HEAD, для выпущенной версии см. Их соответствующие ветки

Ответ 3

К сожалению, OP не смог показать нам исходный код, он решил запутать код, слегка преобразуя его в сборку.

В коде сборки OP связан (аннотации от меня):

.L10:
    cvtsi2sd %ebx, %xmm0         // convert i to double
    xorpd    %xmm1, %xmm1        // zero 
    mulsd    .LC0(%rip), %xmm0   // multiply i with 10.1
    ucomisd   %xmm0, %xmm1       // compare with zero
    jae       .L31               // always below, never jump

    addl    $1, %ebx             // i++
    cmpl    $2147483647, %ebx    // end of for loop
    jne     .L10
    ...
.L31:
    call    log10, log2, whatever...  // this point is never reached

Можно видеть, что вызов log никогда не выполняется, особенно если вы выполните его с помощью gdb. Весь код составляет 2 31 умножения и сравнения двойника.

Это также объясняет ошеломляющее увеличение скорости выполнения функции журнала в 30 раз при компиляции с помощью -O2, если кто-то тоже нашел это странно.

Edit:

for (int n = 1; n < INT_MAX; ++n)
{
    m = n * 10.1;
    sum += log(m);
}

Компилятор не может полностью оптимизировать петли, потому что она не может доказать, что вызов log всегда будет успешным - он имеет побочные эффекты, если аргумент отрицательный. Таким образом, она заменяет цикл сравнением с нулем - выполняется log, если результат умножения меньше или меньше нуля. Это означает, что он никогда не выполняется:-)

То, что остается в цикле, - это умножение и тест, если результат может быть отрицательным.

Интересный результат возникает, если я добавляю -ffast-math к параметрам компилятора, что избавляет компилятор от строгого соответствия IEEE:

ln=0:000000944
lb=0:000000475
lg=0:000000357

Ответ 4

Вы неправильно подходите к проблеме и переходите к выводам.

Использование часов для профилирования недостаточно. Используйте приличный профилировщик вместо часов (gprof или AQTime7). Профилировщик должен иметь возможность предоставлять тайм-ауты в каждой строке. Ваша проблема заключается в том, что вы предполагаете, что узкое место лежит в функции журнала. Однако преобразование int-to-float не очень быстро и может быть узким местом. Другое дело, что gcc поставляется с исходным кодом, который вы можете прочитать.

Теперь , предполагая, что узкое место на самом деле лежит в функции журнала:

Как вы должны знать, удвоения имеют ограниченную точность - всего 15..17 десятичных цифр. Это означает, что с большей логарифмической базой вы быстрее достигнете ситуации, когда вы нажмете предел точности.

т.е. 10^(log10(2^32) + 10^-15) - 2^32 == 9.8895 * 10^-6, но 2^(log2(2^32) + 10^-15) - 2^32 == 2.977 * 10^-6 и 100^(log100(2^32) + 10^-15) - 2^32 == 0.00001977, также log2(INT_MAX) > log10(INT_MAX) Это означает, что с большей логарифмической базой, если функция логарифма пытается "искать" для правильный результат, он скорее ударит по ситуации, когда изменение прогнозируемого результата больше невозможно из-за округления ошибок. Однако это все еще просто предположение.

Существуют и другие способы вычисления логарифма. Например, log10(x) == ln(x)/ln(10), если функция логарифма вычисляла его таким образом, вы получали бы почти одинаковые тайминги.

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

Если ДЕЙСТВИТЕЛЬНО важно иметь более быструю функцию логарифма, а алгоритмическая оптимизация ДЕЙСТВИТЕЛЬНО невозможна (если логарифм действительно является узким местом, например, можно кэшировать результаты), вы можете попытаться найти более быструю реализацию алгоритма, но если я если бы вы в этом случае, я бы просто попытался бросить аппаратное обеспечение в проблему - например, распараллеливая задачу.