web-dev-qa-db-fra.com

Calculs à virgule flottante vs entier sur du matériel moderne

J'effectue des travaux critiques en termes de performances en C++ et nous utilisons actuellement des calculs entiers pour résoudre des problèmes inhérents à la virgule flottante, car "c'est plus rapide". Cela cause beaucoup de problèmes gênants et ajoute beaucoup de code gênant.

Je me souviens avoir lu comment les calculs en virgule flottante étaient si lents environ 38 jours environ, alors que je crois (IIRC) qu’il existait un co-processeur facultatif. Mais de nos jours, avec des processeurs exponentiellement plus complexes et plus puissants, la "vitesse" ne fait aucune différence si vous calculez des nombres à virgule flottante ou des nombres entiers? Surtout que le temps de calcul réel est minime comparé à quelque chose comme provoquer un blocage du pipeline ou aller chercher quelque chose dans la mémoire principale?

Je sais que la bonne réponse consiste à évaluer le matériel cible, quel serait un bon moyen de tester cela? J'ai écrit deux programmes C++ minuscules et comparé leur temps d'exécution à "time" sous Linux, mais le temps d'exécution réel est trop variable (cela ne m'aide pas que je tourne sur un serveur virtuel). À part passer toute la journée à courir des centaines de points de repère, à dessiner des graphiques, etc., puis-je faire quelque chose pour obtenir un test raisonnable de la vitesse relative? Des idées ou des pensées? Est-ce que je me trompe complètement?

Les programmes que j'ai utilisés comme suit ne sont en aucun cas identiques:

#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;
}

Programme 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;
}

Merci d'avance!

Edit: La plate-forme qui me tient à cœur est la norme x86 ou x86-64 qui s’exécute sur des ordinateurs de bureau Linux et Windows.

Edit 2 (collé à partir d'un commentaire ci-dessous): Nous avons actuellement une base de code étendue. Vraiment, je me suis heurté à la généralisation selon laquelle "nous ne devons pas utiliser de méthode float car le calcul des nombres entiers est plus rapide" - et je cherche un moyen (même si cela est vrai) de réfuter cette hypothèse généralisée. Je me rends compte qu'il serait impossible de prédire le résultat exact pour nous sans faire tout le travail et le profiler par la suite.

Quoi qu'il en soit, merci pour toutes vos excellentes réponses et votre aide. N'hésitez pas à ajouter autre chose :).

94
maxpenguin

Hélas, je ne peux que vous donner une réponse "ça dépend" ...

D'après mon expérience, il y a beaucoup, beaucoup de variables à la performance ... surtout entre les mathématiques entières et les calculs en virgule flottante. Cela varie fortement d'un processeur à l'autre (même au sein d'une même famille, telle que x86), car différents processeurs ont des longueurs de "pipeline" différentes. En outre, certaines opérations sont généralement très simples (telles que l’addition) et nécessitent un acheminement accéléré via le processeur, tandis que d’autres (telles que la division) prennent beaucoup plus de temps.

L'autre grande variable est l'endroit où résident les données. Si vous n'avez que quelques valeurs à ajouter, toutes les données peuvent se trouver dans le cache, où elles peuvent être rapidement envoyées à la CPU. Une opération à virgule flottante très très lente qui contient déjà les données dans le cache sera bien plus rapide qu'une opération d'entier où un entier doit être copié à partir de la mémoire système.

Je suppose que vous posez cette question parce que vous travaillez sur une application critique en termes de performances. Si vous développez pour l’architecture x86 et que vous avez besoin de performances supplémentaires, vous voudrez peut-être envisager d’utiliser les extensions SSE. Cela peut considérablement accélérer l’arithmétique en virgule flottante à simple précision, comme le même système). L’opération peut être effectuée sur plusieurs données à la fois, en plus d’une banque de registres * distincte pour les opérations SSE. (Dans votre deuxième exemple, j’ai remarqué que vous utilisiez "float" au lieu de "double" , me faisant penser que vous utilisez des mathématiques à simple précision).

* Remarque: l'utilisation des anciennes instructions MMX ralentirait les programmes, car celles-ci utilisaient en fait les mêmes registres que le FPU, rendant impossible l'utilisation simultanée du FPU et du MMX.

31
Dan

Par exemple (les nombres plus petits sont plus rapides),

Intel Xeon X5550 64 bits @ 2,67 GHz, 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]

Processeur AMD Opteron (tm) double cœur 32 bits 265 @ 1,81 GHz, gcc 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]

Comme Dan fit remarquer , même une fois que vous normalisez la fréquence d'horloge (ce qui peut être trompeur en soi dans les conceptions en pipeline), les résultats varieront énormément en fonction de l'architecture de la CPU (individuel ALU / FPU performance , ainsi que effectif nombre d’ALU/FPU disponible par cœur dans superscalar conceptions qui influent sur le nombre opérations indépendantes pouvant être exécutées en parallèle - ce dernier facteur n'est pas appliqué par le code ci-dessous, toutes les opérations ci-dessous étant séquentielles. dépendant.)

Point de repère des opérations FPU/ALU du pauvre:

#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;
}
48
vladr

Il y aura probablement une différence significative de vitesse dans le monde réel entre les calculs en virgule fixe et en virgule flottante, mais le débit théorique dans le meilleur des cas de l'ALU par rapport au FPU n'a aucune pertinence. Au lieu de cela, le nombre de registres entiers et à virgule flottante (registres réels, pas les noms de registre) de votre architecture qui ne sont pas utilisés par ailleurs par votre calcul (par exemple pour le contrôle de boucle), le nombre d'éléments de chaque type qui tiennent dans une ligne de cache , optimisations possibles en considérant les différentes sémantiques pour les maths entiers et en virgule flottante - ces effets vont dominer. Les dépendances de données de votre algorithme jouent ici un rôle important, de sorte qu'aucune comparaison générale ne permet de prévoir l'écart de performance de votre problème.

Par exemple, l’ajout d’entiers est commutatif. Ainsi, si le compilateur voit une boucle semblable à celle que vous avez utilisée pour un point de repère (en supposant que les données aléatoires ont été préparées à l’avance pour ne pas masquer les résultats), il peut dérouler la boucle et calculer des sommes partielles avec pas de dépendances, puis ajoutez-les lorsque la boucle se termine. Mais avec une virgule flottante, le compilateur doit effectuer les opérations dans le même ordre que vous avez demandé (vous y avez des points de séquence afin que le compilateur garantisse le même résultat, ce qui interdit la réorganisation), de sorte qu'il existe une forte dépendance de chaque ajout le résultat de la précédente.

Vous risquez également de placer simultanément plus d'opérandes entiers dans le cache. Ainsi, la version à point fixe peut surperformer la version à virgule flottante d'un ordre de grandeur même sur une machine où le FPU a théoriquement un débit supérieur.

21
Ben Voigt

L'addition est beaucoup plus rapide que Rand, votre programme est donc (surtout) inutile.

Vous devez identifier les points chauds de performances et modifier progressivement votre programme. Il semble que vous ayez des problèmes avec votre environnement de développement qui devront être résolus en premier. Est-il impossible d'exécuter votre programme sur votre PC pour un petit ensemble de problèmes?

Généralement, essayer FP avec un calcul arithmétique entier est une recette pour lent.

18
Potatoswatter

TIL Cela varie (beaucoup). Voici quelques résultats utilisant le compilateur gnu (d'ailleurs, j'ai également vérifié en compilant sur des machines, gnu g ++ 5.4 de xenial est bien plus rapide que 4.6.3 de 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 a des résultats similaires

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 sous 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

Droplet DigitalOcean 1 Go E5-2630L v2 du processeur Intel (X) Xeon (MD) (en cours d'exécution de confiance)

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

Processeur AMD Opteron (tm) 4122 (précis)

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

Ceci utilise le code de http://Pastebin.com/Kx8WGUfg en tant que benchmark-pc.c

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

J'ai effectué plusieurs passes, mais il semble que les chiffres généraux soient les mêmes.

Une exception notable semble être ALU mul vs FPU mul. L'addition et la soustraction semblent trivialement différentes.

Voici ce qui précède sous forme de graphique (cliquez pour agrandir, plus bas est rapide et préférable):

Chart of above data

Mise à jour pour accueillir @Peter Cordes

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

    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
    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
    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
14
MrMesees

Deux points à considérer -

Le matériel moderne peut chevaucher des instructions, les exécuter en parallèle et les réorganiser pour utiliser au mieux le matériel. Et aussi, tout programme à virgule flottante significatif aura probablement aussi un travail d’entier important, même s’il ne calcule que des index dans des tableaux, un compteur de boucles, etc. chevauchant avec une partie du travail entier. Mon point étant que même si les instructions en virgule flottante sont lentes, celles de l'ensemble, votre programme global peut s'exécuter plus rapidement car il peut utiliser davantage de matériel.

Comme toujours, la seule façon d’en être sûr est de profiler votre programme actuel.

Le deuxième point est que la plupart des processeurs ont de nos jours des instructions SIMD pour la virgule flottante pouvant fonctionner sur plusieurs valeurs à virgule flottante en même temps. Par exemple, vous pouvez charger 4 flotteurs dans un seul registre SSE et effectuer 4 multiplications sur tous en parallèle. Si vous pouvez réécrire des parties de votre code pour utiliser les instructions SSE, il semble probable que ce sera plus rapide qu'une version entière. Visual c ++ fournit des fonctions intrinsèques du compilateur pour ce faire, voir http://msdn.Microsoft.com/en-us/library/x5c07e2a (v = VS.80) .aspx pour obtenir des informations.

7
jcoder

Sauf si vous écrivez du code appelé des millions de fois par seconde (comme, par exemple, tracer une ligne à l'écran dans une application graphique), l'arithmétique des nombres entiers par rapport aux points à virgule flottante est rarement le goulot d'étranglement.

La première étape habituelle des questions d'efficacité consiste à profiler votre code pour voir où le temps d'exécution est réellement passé. La commande linux pour cela est gprof.

Modifier:

Bien que je suppose que vous puissiez toujours implémenter l'algorithme de dessin au trait en utilisant des nombres entiers et des nombres à virgule flottante, appelez-le un grand nombre de fois et voyez si cela fait une différence:

http://en.wikipedia.org/wiki/algorithme de Bresenham

4
Artem Sokolov

Aujourd'hui, les opérations sur les nombres entiers sont généralement un peu plus rapides que les opérations à virgule flottante. Donc, si vous pouvez effectuer un calcul avec les mêmes opérations en nombre entier et en virgule flottante, utilisez un nombre entier. TOUTEFOIS, vous dites "Cela cause beaucoup de problèmes gênants et ajoute beaucoup de code gênant". On dirait que vous avez besoin de plus d'opérations parce que vous utilisez l'arithmétique entière au lieu de virgule flottante. Dans ce cas, la virgule flottante sera plus rapide car

  • dès que vous aurez besoin d'opérations supplémentaires sur les nombres entiers, vous aurez probablement besoin de beaucoup plus, de sorte que le léger avantage lié à la vitesse est plus que consommé par les opérations supplémentaires.

  • le code en virgule flottante est plus simple, ce qui signifie que l'écriture du code est plus rapide, ce qui signifie que si la vitesse est critique, vous pouvez passer plus de temps à l'optimisation du code.

4
gnasher729

La version à virgule flottante sera beaucoup plus lente s'il n'y a pas d'opération de reste. Comme tous les ajouts sont séquentiels, l'unité centrale ne sera pas en mesure de mettre en parallèle la somme. La latence sera critique. La latence d’ajout de FPU est généralement de 3 cycles, tandis que l’ajout d’entier est de 1 cycle. Cependant, le séparateur pour le reste de l’opérateur sera probablement la partie critique, car il n’est pas entièrement en pipeline sur les processeurs modernes. par conséquent, en supposant que l’instruction divide/reste prenne la majeure partie du temps, la différence due à l’ajout de temps de latence sera faible.

4
Goran D

J'ai couru un test qui a juste ajouté 1 au nombre à la place de Rand (). Les résultats (sur un x86 à 64) étaient:

  • court: 4.260s
  • int: 4.020s
  • long long: 3.350s
  • float: 7.330s
  • double: 7.210s
3
dan04