Плавающая точка против целочисленных вычислений на современном оборудовании - PullRequest
95 голосов
/ 31 марта 2010

Я делаю некоторую критичную для производительности работу в C ++, и в настоящее время мы используем целочисленные вычисления для задач, которые по своей природе являются числами с плавающей запятой, потому что «это быстрее». Это вызывает много раздражающих проблем и добавляет много раздражающего кода.

Теперь я помню, как читал о том, как вычисления с плавающей запятой были настолько медленными примерно за 386 дней, где, как я полагаю (IIRC), был дополнительный сопроцессор. Но, конечно, в наше время с экспоненциально более сложными и мощными процессорами не имеет значения в «скорости», если выполнять вычисления с плавающей запятой или целочисленные? Тем более что фактическое время вычислений ничтожно по сравнению с чем-то вроде вызова останова конвейера или извлечения чего-либо из основной памяти?

Я знаю, что правильный ответ - это тестирование на целевом оборудовании, что будет хорошим способом проверить это? Я написал две крошечные программы на C ++ и сравнил их время выполнения с «временем» в Linux, но фактическое время выполнения слишком изменчиво (не помогает мне работать на виртуальном сервере). Если не считать того, что я провел весь день, выполняя сотни тестов, составляя графики и т. Д., Могу ли я что-то сделать, чтобы получить разумную проверку относительной скорости? Есть идеи или мысли? Я совершенно не прав?

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

#include <iostream>
#include <cmath>
#include <cstdlib>
#include <time.h>

int main( int argc, char** argv )
{
    int accum = 0;

    srand( time( NULL ) );

    for( unsigned int i = 0; i < 100000000; ++i )
    {
        accum += rand( ) % 365;
    }
    std::cout << accum << std::endl;

    return 0;
}

Программа 2:

#include <iostream>
#include <cmath>
#include <cstdlib>
#include <time.h>

int main( int argc, char** argv )
{

    float accum = 0;
    srand( time( NULL ) );

    for( unsigned int i = 0; i < 100000000; ++i )
    {
        accum += (float)( rand( ) % 365 );
    }
    std::cout << accum << std::endl;

    return 0;
}

Заранее спасибо!

Редактировать: Платформа, о которой я забочусь, - это обычные x86 или x86-64, работающие на настольных компьютерах с Linux и Windows.

Редактировать 2 (вставлено из комментария ниже): В настоящее время у нас обширная база кода. На самом деле я столкнулся с обобщением, что мы «не должны использовать float, поскольку целочисленные вычисления быстрее» - и я ищу способ (если это даже верно) опровергнуть это обобщенное предположение. Я понимаю, что было бы невозможно предсказать точный результат для нас, если бы мы не выполнили всю работу и не профилировали ее впоследствии.

В любом случае, спасибо за все ваши прекрасные ответы и помощь. Не стесняйтесь добавлять что-нибудь еще:).

Ответы [ 11 ]

47 голосов
/ 31 марта 2010

Например (меньшие числа быстрее),

64-битный Intel Xeon X5550 @ 2,67 ГГц, gcc 4.1.2 -O3

short add/sub: 1.005460 [0]
short mul/div: 3.926543 [0]
long add/sub: 0.000000 [0]
long mul/div: 7.378581 [0]
long long add/sub: 0.000000 [0]
long long mul/div: 7.378593 [0]
float add/sub: 0.993583 [0]
float mul/div: 1.821565 [0]
double add/sub: 0.993884 [0]
double mul/div: 1.988664 [0]

32-разрядный двухъядерный процессор AMD Opteron (tm) 265 @ 1,81 ГГц, ГЦК 3,4,6 -O3

short add/sub: 0.553863 [0]
short mul/div: 12.509163 [0]
long add/sub: 0.556912 [0]
long mul/div: 12.748019 [0]
long long add/sub: 5.298999 [0]
long long mul/div: 20.461186 [0]
float add/sub: 2.688253 [0]
float mul/div: 4.683886 [0]
double add/sub: 2.700834 [0]
double mul/div: 4.646755 [0]

Как указал Дэн , даже если вы нормализуете тактовую частоту (которая может вводить в заблуждение сама по себе в конвейерных конструкциях), результаты будут сильно отличаться в зависимости от архитектуры ЦП (индивидуально *) 1015 * ALU / FPU производительность , , а также фактическое количество ALU / FPU доступных на ядро ​​в суперскалярные проекты, которые влияют на то, сколько независимых операций может выполняться параллельно - последний фактор не выполняется в приведенном ниже коде, поскольку все операции ниже зависят друг от друга.)

Эталонный тест для бедных людей FPU / ALU:

#include <stdio.h>
#ifdef _WIN32
#include <sys/timeb.h>
#else
#include <sys/time.h>
#endif
#include <time.h>
#include <cstdlib>

double
mygettime(void) {
# ifdef _WIN32
  struct _timeb tb;
  _ftime(&tb);
  return (double)tb.time + (0.001 * (double)tb.millitm);
# else
  struct timeval tv;
  if(gettimeofday(&tv, 0) < 0) {
    perror("oops");
  }
  return (double)tv.tv_sec + (0.000001 * (double)tv.tv_usec);
# endif
}

template< typename Type >
void my_test(const char* name) {
  Type v  = 0;
  // Do not use constants or repeating values
  //  to avoid loop unroll optimizations.
  // All values >0 to avoid division by 0
  // Perform ten ops/iteration to reduce
  //  impact of ++i below on measurements
  Type v0 = (Type)(rand() % 256)/16 + 1;
  Type v1 = (Type)(rand() % 256)/16 + 1;
  Type v2 = (Type)(rand() % 256)/16 + 1;
  Type v3 = (Type)(rand() % 256)/16 + 1;
  Type v4 = (Type)(rand() % 256)/16 + 1;
  Type v5 = (Type)(rand() % 256)/16 + 1;
  Type v6 = (Type)(rand() % 256)/16 + 1;
  Type v7 = (Type)(rand() % 256)/16 + 1;
  Type v8 = (Type)(rand() % 256)/16 + 1;
  Type v9 = (Type)(rand() % 256)/16 + 1;

  double t1 = mygettime();
  for (size_t i = 0; i < 100000000; ++i) {
    v += v0;
    v -= v1;
    v += v2;
    v -= v3;
    v += v4;
    v -= v5;
    v += v6;
    v -= v7;
    v += v8;
    v -= v9;
  }
  // Pretend we make use of v so compiler doesn't optimize out
  //  the loop completely
  printf("%s add/sub: %f [%d]\n", name, mygettime() - t1, (int)v&1);
  t1 = mygettime();
  for (size_t i = 0; i < 100000000; ++i) {
    v /= v0;
    v *= v1;
    v /= v2;
    v *= v3;
    v /= v4;
    v *= v5;
    v /= v6;
    v *= v7;
    v /= v8;
    v *= v9;
  }
  // Pretend we make use of v so compiler doesn't optimize out
  //  the loop completely
  printf("%s mul/div: %f [%d]\n", name, mygettime() - t1, (int)v&1);
}

int main() {
  my_test< short >("short");
  my_test< long >("long");
  my_test< long long >("long long");
  my_test< float >("float");
  my_test< double >("double");

  return 0;
}
31 голосов
/ 31 марта 2010

Увы, я могу дать вам только ответ "все зависит" ...

Исходя из моего опыта, производительность зависит от множества факторов, особенно между целочисленными и математическими вычислениями с плавающей запятой. Он сильно варьируется от процессора к процессору (даже в пределах одного семейства, такого как x86), поскольку разные процессоры имеют разную длину конвейера. Кроме того, некоторые операции, как правило, очень просты (например, сложение) и имеют ускоренный маршрут через процессор, а другие (например, деление) занимают гораздо больше времени.

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

Я предполагаю, что вы задаете этот вопрос, потому что работаете над приложением, критичным к производительности. Если вы разрабатываете для архитектуры x86 и вам нужна дополнительная производительность, вы можете захотеть использовать расширения SSE. Это может значительно ускорить арифметику с плавающей запятой одинарной точности, поскольку одна и та же операция может быть выполнена для нескольких данных одновременно, плюс имеется отдельный банк регистров для операций SSE. (Я заметил, что во втором примере вы использовали «float» вместо «double», что заставляет меня думать, что вы используете математику с одинарной точностью).

* Примечание. Использование старых инструкций MMX фактически приведет к замедлению работы программ, поскольку эти старые инструкции фактически используют те же регистры, что и FPU, что делает невозможным использование одновременно FPU и MMX.

19 голосов
/ 31 марта 2010

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

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

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

18 голосов
/ 31 марта 2010

Добавление намного быстрее, чем rand, поэтому ваша программа (особенно) бесполезна.

Вам необходимо определить «горячие точки» производительности и постепенно изменять вашу программу. Похоже, у вас есть проблемы со средой разработки, которые необходимо решить в первую очередь. Разве невозможно запустить программу на вашем компьютере для небольшого набора проблем?

Как правило, попытка задания FP с целочисленной арифметикой - это рецепт медленного.

13 голосов
/ 10 января 2017

TIL Это варьируется (много). Вот некоторые результаты использования компилятора gnu (кстати, я также проверил компиляцию на машинах, gnu g ++ 5.4 из xenial намного быстрее, чем 4.6.3 из linaro на точном)

Intel i7 4700MQ xenial

short add: 0.822491
short sub: 0.832757
short mul: 1.007533
short div: 3.459642
long add: 0.824088
long sub: 0.867495
long mul: 1.017164
long div: 5.662498
long long add: 0.873705
long long sub: 0.873177
long long mul: 1.019648
long long div: 5.657374
float add: 1.137084
float sub: 1.140690
float mul: 1.410767
float div: 2.093982
double add: 1.139156
double sub: 1.146221
double mul: 1.405541
double div: 2.093173

Intel i3 2370M имеет похожие результаты

short add: 1.369983
short sub: 1.235122
short mul: 1.345993
short div: 4.198790
long add: 1.224552
long sub: 1.223314
long mul: 1.346309
long div: 7.275912
long long add: 1.235526
long long sub: 1.223865
long long mul: 1.346409
long long div: 7.271491
float add: 1.507352
float sub: 1.506573
float mul: 2.006751
float div: 2.762262
double add: 1.507561
double sub: 1.506817
double mul: 1.843164
double div: 2.877484

Intel (R) Celeron (R) 2955U (Chromebook Acer C720 под управлением xenial)

short add: 1.999639
short sub: 1.919501
short mul: 2.292759
short div: 7.801453
long add: 1.987842
long sub: 1.933746
long mul: 2.292715
long div: 12.797286
long long add: 1.920429
long long sub: 1.987339
long long mul: 2.292952
long long div: 12.795385
float add: 2.580141
float sub: 2.579344
float mul: 3.152459
float div: 4.716983
double add: 2.579279
double sub: 2.579290
double mul: 3.152649
double div: 4.691226

Цикл DigitalOcean, 1 ГБ, процессор Intel® Xeon®, E5-2630L v2 (работает надежно)

short add: 1.094323
short sub: 1.095886
short mul: 1.356369
short div: 4.256722
long add: 1.111328
long sub: 1.079420
long mul: 1.356105
long div: 7.422517
long long add: 1.057854
long long sub: 1.099414
long long mul: 1.368913
long long div: 7.424180
float add: 1.516550
float sub: 1.544005
float mul: 1.879592
float div: 2.798318
double add: 1.534624
double sub: 1.533405
double mul: 1.866442
double div: 2.777649

Процессор AMD Opteron (tm) 4122 (точный)

short add: 3.396932
short sub: 3.530665
short mul: 3.524118
short div: 15.226630
long add: 3.522978
long sub: 3.439746
long mul: 5.051004
long div: 15.125845
long long add: 4.008773
long long sub: 4.138124
long long mul: 5.090263
long long div: 14.769520
float add: 6.357209
float sub: 6.393084
float mul: 6.303037
float div: 17.541792
double add: 6.415921
double sub: 6.342832
double mul: 6.321899
double div: 15.362536

При этом используется код из http://pastebin.com/Kx8WGUfg как benchmark-pc.c

g++ -fpermissive -O3 -o benchmark-pc benchmark-pc.c

Я провел несколько проходов, но, похоже, это тот случай, когда общие числа совпадают.

Кажется, что одним заметным исключением является ALU mul против FPU mul. Сложение и вычитание кажутся тривиально разными.

Вот выше в форме диаграммы (нажмите для увеличения, ниже, быстрее и предпочтительнее):

Chart of above data

Обновление для размещения @Peter Cordes

https://gist.github.com/Lewiscowles1986/90191c59c9aedf3d08bf0b129065cccc

i7 4700MQ Linux Ubuntu Xenial 64-bit (все исправления до 2018-03-13 применены)
    short add: 0.773049
    short sub: 0.789793
    short mul: 0.960152
    short div: 3.273668
      int add: 0.837695
      int sub: 0.804066
      int mul: 0.960840
      int div: 3.281113
     long add: 0.829946
     long sub: 0.829168
     long mul: 0.960717
     long div: 5.363420
long long add: 0.828654
long long sub: 0.805897
long long mul: 0.964164
long long div: 5.359342
    float add: 1.081649
    float sub: 1.080351
    float mul: 1.323401
    float div: 1.984582
   double add: 1.081079
   double sub: 1.082572
   double mul: 1.323857
   double div: 1.968488
Процессор AMD Opteron (tm) 4122 (точный, общий хостинг DreamHost)
    short add: 1.235603
    short sub: 1.235017
    short mul: 1.280661
    short div: 5.535520
      int add: 1.233110
      int sub: 1.232561
      int mul: 1.280593
      int div: 5.350998
     long add: 1.281022
     long sub: 1.251045
     long mul: 1.834241
     long div: 5.350325
long long add: 1.279738
long long sub: 1.249189
long long mul: 1.841852
long long div: 5.351960
    float add: 2.307852
    float sub: 2.305122
    float mul: 2.298346
    float div: 4.833562
   double add: 2.305454
   double sub: 2.307195
   double mul: 2.302797
   double div: 5.485736
Intel Xeon E5-2630L v2 с частотой 2,4 ГГц (надежный 64-разрядный, DigitalOcean VPS)
    short add: 1.040745
    short sub: 0.998255
    short mul: 1.240751
    short div: 3.900671
      int add: 1.054430
      int sub: 1.000328
      int mul: 1.250496
      int div: 3.904415
     long add: 0.995786
     long sub: 1.021743
     long mul: 1.335557
     long div: 7.693886
long long add: 1.139643
long long sub: 1.103039
long long mul: 1.409939
long long div: 7.652080
    float add: 1.572640
    float sub: 1.532714
    float mul: 1.864489
    float div: 2.825330
   double add: 1.535827
   double sub: 1.535055
   double mul: 1.881584
   double div: 2.777245
7 голосов
/ 31 марта 2010

Два вопроса для рассмотрения -

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

Как всегда, единственный способ убедиться в этом - профилировать вашу реальную программу.

Второй момент заключается в том, что в настоящее время большинство процессоров имеют SIMD-инструкции для операций с плавающей запятой, которые могут работать с несколькими значениями с плавающей запятой одновременно. Например, вы можете загрузить 4 числа с плавающей запятой в один регистр SSE и выполнить 4 умножения на них все параллельно. Если вы можете переписать части своего кода для использования инструкций SSE, то, скорее всего, это будет быстрее, чем целочисленная версия. Visual C ++ предоставляет встроенные функции компилятора, чтобы сделать это, см. http://msdn.microsoft.com/en-us/library/x5c07e2a(v=VS.80).aspx для получения дополнительной информации.

4 голосов
/ 03 апреля 2014

Сегодня целочисленные операции обычно немного быстрее, чем операции с плавающей запятой. Поэтому, если вы можете выполнять вычисления с одинаковыми операциями в целочисленных и с плавающей запятой, используйте целочисленные. ОДНАКО вы говорите: «Это вызывает много раздражающих проблем и добавляет много раздражающего кода». Похоже, вам нужно больше операций, потому что вы используете целочисленную арифметику вместо числа с плавающей запятой. В этом случае с плавающей точкой будет работать быстрее, потому что

  • как только вам понадобится больше целочисленных операций, вам, вероятно, понадобится намного больше, поэтому небольшое преимущество в скорости более чем съедено дополнительными операциями

  • код с плавающей точкой проще, что означает, что он быстрее пишется, что означает, что, если он критичен по скорости, вы можете потратить больше времени на оптимизацию кода.

4 голосов
/ 31 марта 2010

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

Обычный первый шаг к вопросам эффективности - это профилирование вашего кода, чтобы увидеть, где на самом деле тратится время выполнения. Команда Linux для этого: gprof.

Edit:

Хотя я полагаю, что вы всегда можете реализовать алгоритм рисования линий, используя целые числа и числа с плавающей запятой, назовите его большое количество раз и посмотрите, будет ли он иметь значение:

http://en.wikipedia.org/wiki/Bresenham's_algorithm

3 голосов
/ 22 декабря 2014

Версия с плавающей запятой будет намного медленнее, если нет остаточной операции. Поскольку все добавления являются последовательными, процессор не сможет распараллелить суммирование. Задержка будет критической. Задержка добавления FPU обычно составляет 3 такта, а целочисленное добавление - 1 такт. Тем не менее, делитель для оператора остатка, вероятно, будет критической частью, так как он не полностью конвейеризован на современных процессорах. поэтому, предполагая, что инструкция деления / остатка будет занимать большую часть времени, разница из-за задержки добавления будет небольшой.

3 голосов
/ 31 марта 2010

Я запустил тест, который просто добавил 1 к числу вместо rand (). Результаты (на x86-64) были:

  • короткий: 4,260 с
  • int: 4.020s
  • длинный длинный: 3,350 с
  • float: 7,330 с
  • двойной: 7,210 с
...