Quelle est la vitesse de D par rapport au C ++?


133

J'aime certaines fonctionnalités de D, mais serais-je intéressé si elles venaient avec une pénalité d'exécution?

Pour comparer, j'ai implémenté un programme simple qui calcule les produits scalaires de nombreux vecteurs courts à la fois en C ++ et en D. Le résultat est surprenant:

  • D: 18,9 s [voir ci-dessous pour l'exécution finale]
  • C ++: 3,8 s

Le C ++ est-il vraiment presque cinq fois plus rapide ou ai-je commis une erreur dans le programme D?

J'ai compilé C ++ avec g ++ -O3 (gcc-snapshot 2011-02-19) et D avec dmd -O (dmd 2.052) sur un bureau Linux récent et modéré. Les résultats sont reproductibles sur plusieurs essais et les écarts types sont négligeables.

Voici le programme C ++:

#include <iostream>
#include <random>
#include <chrono>
#include <string>

#include <vector>
#include <array>

typedef std::chrono::duration<long, std::ratio<1, 1000>> millisecs;
template <typename _T>
long time_since(std::chrono::time_point<_T>& time) {
      long tm = std::chrono::duration_cast<millisecs>( std::chrono::system_clock::now() - time).count();
  time = std::chrono::system_clock::now();
  return tm;
}

const long N = 20000;
const int size = 10;

typedef int value_type;
typedef long long result_type;
typedef std::vector<value_type> vector_t;
typedef typename vector_t::size_type size_type;

inline value_type scalar_product(const vector_t& x, const vector_t& y) {
  value_type res = 0;
  size_type siz = x.size();
  for (size_type i = 0; i < siz; ++i)
    res += x[i] * y[i];
  return res;
}

int main() {
  auto tm_before = std::chrono::system_clock::now();

  // 1. allocate and fill randomly many short vectors
  vector_t* xs = new vector_t [N];
  for (int i = 0; i < N; ++i) {
    xs[i] = vector_t(size);
      }
  std::cerr << "allocation: " << time_since(tm_before) << " ms" << std::endl;

  std::mt19937 rnd_engine;
  std::uniform_int_distribution<value_type> runif_gen(-1000, 1000);
  for (int i = 0; i < N; ++i)
    for (int j = 0; j < size; ++j)
      xs[i][j] = runif_gen(rnd_engine);
  std::cerr << "random generation: " << time_since(tm_before) << " ms" << std::endl;

  // 2. compute all pairwise scalar products:
  time_since(tm_before);
  result_type avg = 0;
  for (int i = 0; i < N; ++i)
    for (int j = 0; j < N; ++j) 
      avg += scalar_product(xs[i], xs[j]);
  avg = avg / N*N;
  auto time = time_since(tm_before);
  std::cout << "result: " << avg << std::endl;
  std::cout << "time: " << time << " ms" << std::endl;
}

Et voici la version D:

import std.stdio;
import std.datetime;
import std.random;

const long N = 20000;
const int size = 10;

alias int value_type;
alias long result_type;
alias value_type[] vector_t;
alias uint size_type;

value_type scalar_product(const ref vector_t x, const ref vector_t y) {
  value_type res = 0;
  size_type siz = x.length;
  for (size_type i = 0; i < siz; ++i)
    res += x[i] * y[i];
  return res;
}

int main() {   
  auto tm_before = Clock.currTime();

  // 1. allocate and fill randomly many short vectors
  vector_t[] xs;
  xs.length = N;
  for (int i = 0; i < N; ++i) {
    xs[i].length = size;
  }
  writefln("allocation: %i ", (Clock.currTime() - tm_before));
  tm_before = Clock.currTime();

  for (int i = 0; i < N; ++i)
    for (int j = 0; j < size; ++j)
      xs[i][j] = uniform(-1000, 1000);
  writefln("random: %i ", (Clock.currTime() - tm_before));
  tm_before = Clock.currTime();

  // 2. compute all pairwise scalar products:
  result_type avg = cast(result_type) 0;
  for (int i = 0; i < N; ++i)
    for (int j = 0; j < N; ++j) 
      avg += scalar_product(xs[i], xs[j]);
  avg = avg / N*N;
  writefln("result: %d", avg);
  auto time = Clock.currTime() - tm_before;
  writefln("scalar products: %i ", time);

  return 0;
}

3
Au fait, votre programme a un bogue sur cette ligne: avg = avg / N*N(ordre des opérations).
Vladimir Panteleev

4
Vous pouvez essayer de réécrire le code en utilisant des opérations de tableau / vecteur digitalmars.com/d/2.0/arrays.html
Michal Minich

10
Pour fournir une meilleure comparaison, vous devez utiliser le même back-end du compilateur. Soit DMD et DMC ++, soit GDC et G ++
he_the_great

1
@Sion Sheevok Malheureusement, le profilage dmd ne semble pas disponible pour Linux? (Veuillez me corriger si je me trompe, mais si je dis dmd ... trace.defque j'obtiens un error: unrecognized file extension def. Et la documentation dmd pour optlink ne mentionne que Windows.
Lars

1
Ah, je ne me suis jamais soucié de ce fichier .def qu'il crache. Les horaires sont à l'intérieur du fichier .log. "Il contient la liste des fonctions dans l'ordre dans lequel l'éditeur de liens doit les lier" - peut-être que cela aide optlink à optimiser quelque chose? Notez également que "De plus, ld prend entièrement en charge les fichiers standard" * .def ", qui peuvent être spécifiés sur la ligne de commande de l'éditeur de liens comme un fichier objet" - vous pouvez donc essayer de passer trace.def via -L si vous le souhaitez. à.
Trass3r

Réponses:


64

Pour activer toutes les optimisations et désactiver tous les contrôles de sécurité, compilez votre programme D avec les indicateurs DMD suivants:

-O -inline -release -noboundscheck

EDIT : J'ai essayé vos programmes avec g ++, dmd et gdc. dmd est à la traîne, mais gdc atteint des performances très proches de g ++. La ligne de commande que j'ai utilisée était gdmd -O -release -inline(gdmd est un wrapper autour de gdc qui accepte les options dmd).

En regardant la liste de l'assembleur, il semble que ni dmd ni gdc ne soient intégrés scalar_product, mais g ++ / gdc a émis des instructions MMX, donc ils pourraient vectoriser automatiquement la boucle.


3
@CyberShadow: mais si vous supprimez le contrôle de sécurité ... ne perdez-vous pas certaines fonctionnalités importantes de D?
Matthieu M.

33
Vous perdez des fonctionnalités que C ++ n'avait jamais eues. La plupart des langues ne vous donnent pas le choix.
Vladimir Panteleev

6
@CyberShadow: pouvons-nous considérer cela comme une sorte de build de débogage vs version de version?
Francesco

7
@Bernard: dans -release, la vérification des limites est désactivée pour tout le code à l'exception des fonctions sûres. pour vraiment désactiver la vérification des limites, utilisez à la fois -release et-noboundscheck.
Michal Minich

5
@CyberShadow Merci! Avec ces indicateurs, le temps d'exécution s'améliore considérablement. Maintenant D est à 12,9 s. Mais fonctionne toujours plus de 3 fois plus longtemps. @Matthieu M. Cela ne me dérangerait pas de tester un programme avec boundschecking au ralenti et une fois débogué, laissez-le faire ses calculs sans boundschecking. (Je fais la même chose avec C ++ maintenant.)
Lars

32

Une grande chose qui ralentit D est une implémentation de garbage collection inférieure à la moyenne. Les benchmarks qui ne stressent pas fortement le GC afficheront des performances très similaires à celles du code C et C ++ compilé avec le même backend du compilateur. Les repères qui insistent fortement sur le GC montreront que D fonctionne de manière épouvantable. Soyez assuré, cependant, qu'il s'agit d'un problème de qualité de mise en œuvre unique (bien que grave), et non d'une garantie de lenteur. En outre, D vous donne la possibilité de désactiver le GC et d'ajuster la gestion de la mémoire dans les bits critiques pour les performances, tout en l'utilisant dans les 95% moins critiques de votre code.

Ces derniers temps, j'ai déployé des efforts pour améliorer les performances du GC et les résultats ont été plutôt spectaculaires, du moins sur des benchmarks synthétiques. Espérons que ces changements seront intégrés dans l'une des prochaines versions et atténueront le problème.


1
J'ai remarqué que l'un de vos changements était un changement de division en changement de bit. Cela ne devrait-il pas être quelque chose que le compilateur fait?
GManNickG

3
@GMan: Oui, si la valeur par laquelle vous divisez est connue au moment de la compilation. Non, si la valeur n'est connue qu'au moment de l'exécution, ce qui était le cas où j'ai fait cette optimisation.
dsimcha

@dsimcha: Hum. Je suppose que si vous savez le faire, le compilateur peut aussi. Problème de qualité de mise en œuvre, ou est-ce que je manque qu'une condition doit être satisfaite que le compilateur ne peut pas prouver, mais vous savez? (
J'apprends

13
@GMan: Le décalage de bits ne fonctionne que si le nombre par lequel vous divisez est une puissance de deux. Le compilateur ne peut pas le prouver si le nombre n'est connu qu'au moment de l'exécution, et les tests et le branchement seraient plus lents que d'utiliser simplement l'instruction div. Mon cas est inhabituel car la valeur n'est connue qu'au moment de l'exécution, mais je sais au moment de la compilation que cela va être une puissance de deux.
dsimcha

7
Notez que le programme publié dans cet exemple ne fait pas d'allocation dans la partie chronophage.
Vladimir Panteleev

27

C'est un fil très instructif, merci pour tout le travail au PO et aux aides.

Une note - ce test n'évalue pas la question générale de l'abstraction / pénalité des fonctionnalités ou même celle de la qualité du backend. Il se concentre sur pratiquement une optimisation (optimisation de boucle). Je pense qu'il est juste de dire que le backend de gcc est un peu plus raffiné que celui de dmd, mais ce serait une erreur de supposer que l'écart entre eux est aussi grand pour toutes les tâches.


4
Je suis complètement d'accord. Comme ajouté plus tard, je suis principalement intéressé par les performances pour les calculs numériques où l'optimisation des boucles est probablement la plus importante. Selon vous, quelles autres optimisations seraient importantes pour le calcul numérique? Et quels calculs les testeraient? Je serais intéressé de compléter mon test et de mettre en œuvre d'autres tests (s'ils sont à peu près aussi simples). Mais evtl. c'est une autre question en soi?
Lars

11
En tant qu'ingénieur qui a fait ses armes sur le C ++, vous êtes un de mes héros. Respectueusement, cependant, cela devrait être un commentaire, pas une réponse.
Alan

14

Cela semble définitivement être un problème de qualité de mise en œuvre.

J'ai effectué quelques tests avec le code de l'OP et apporté quelques modifications. En fait, j'ai accéléré D pour LDC / clang ++, en partant du principe que les tableaux doivent être alloués dynamiquement ( xset les scalaires associés). Voir ci-dessous pour quelques chiffres.

Questions pour le PO

Est-il intentionnel que la même graine soit utilisée pour chaque itération de C ++, alors que ce n'est pas le cas pour D?

Installer

J'ai modifié la source D originale (doublée scalar.d) pour la rendre portable entre les plates-formes. Cela impliquait uniquement de changer le type des nombres utilisés pour accéder et modifier la taille des tableaux.

Après cela, j'ai apporté les modifications suivantes:

  • Utilisé uninitializedArraypour éviter les inits par défaut pour les scalaires dans xs (cela a probablement fait la plus grande différence). Ceci est important parce que D installe normalement tout par défaut en silence, ce que C ++ ne fait pas.

  • Code d'impression factorisé et remplacé writeflnparwriteln

  • Importations modifiées pour être sélectives
  • Opérateur pow utilisé ( ^^) au lieu de la multiplication manuelle pour la dernière étape du calcul de la moyenne
  • Supprimé size_typeet remplacé de manière appropriée par le nouvel index_typealias

... résultant ainsi en scalar2.cpp( pastebin ):

    import std.stdio : writeln;
    import std.datetime : Clock, Duration;
    import std.array : uninitializedArray;
    import std.random : uniform;

    alias result_type = long;
    alias value_type = int;
    alias vector_t = value_type[];
    alias index_type = typeof(vector_t.init.length);// Make index integrals portable - Linux is ulong, Win8.1 is uint

    immutable long N = 20000;
    immutable int size = 10;

    // Replaced for loops with appropriate foreach versions
    value_type scalar_product(in ref vector_t x, in ref vector_t y) { // "in" is the same as "const" here
      value_type res = 0;
      for(index_type i = 0; i < size; ++i)
        res += x[i] * y[i];
      return res;
    }

    int main() {
      auto tm_before = Clock.currTime;
      auto countElapsed(in string taskName) { // Factor out printing code
        writeln(taskName, ": ", Clock.currTime - tm_before);
        tm_before = Clock.currTime;
      }

      // 1. allocate and fill randomly many short vectors
      vector_t[] xs = uninitializedArray!(vector_t[])(N);// Avoid default inits of inner arrays
      for(index_type i = 0; i < N; ++i)
        xs[i] = uninitializedArray!(vector_t)(size);// Avoid more default inits of values
      countElapsed("allocation");

      for(index_type i = 0; i < N; ++i)
        for(index_type j = 0; j < size; ++j)
          xs[i][j] = uniform(-1000, 1000);
      countElapsed("random");

      // 2. compute all pairwise scalar products:
      result_type avg = 0;
      for(index_type i = 0; i < N; ++i)
        for(index_type j = 0; j < N; ++j)
          avg += scalar_product(xs[i], xs[j]);
      avg /= N ^^ 2;// Replace manual multiplication with pow operator
      writeln("result: ", avg);
      countElapsed("scalar products");

      return 0;
    }

Après les tests scalar2.d(qui ont donné la priorité à l'optimisation de la vitesse), par curiosité, j'ai remplacé les boucles mainpar des foreachéquivalents et l' ai appelé scalar3.d( pastebin ):

    import std.stdio : writeln;
    import std.datetime : Clock, Duration;
    import std.array : uninitializedArray;
    import std.random : uniform;

    alias result_type = long;
    alias value_type = int;
    alias vector_t = value_type[];
    alias index_type = typeof(vector_t.init.length);// Make index integrals portable - Linux is ulong, Win8.1 is uint

    immutable long N = 20000;
    immutable int size = 10;

    // Replaced for loops with appropriate foreach versions
    value_type scalar_product(in ref vector_t x, in ref vector_t y) { // "in" is the same as "const" here
      value_type res = 0;
      for(index_type i = 0; i < size; ++i)
        res += x[i] * y[i];
      return res;
    }

    int main() {
      auto tm_before = Clock.currTime;
      auto countElapsed(in string taskName) { // Factor out printing code
        writeln(taskName, ": ", Clock.currTime - tm_before);
        tm_before = Clock.currTime;
      }

      // 1. allocate and fill randomly many short vectors
      vector_t[] xs = uninitializedArray!(vector_t[])(N);// Avoid default inits of inner arrays
      foreach(ref x; xs)
        x = uninitializedArray!(vector_t)(size);// Avoid more default inits of values
      countElapsed("allocation");

      foreach(ref x; xs)
        foreach(ref val; x)
          val = uniform(-1000, 1000);
      countElapsed("random");

      // 2. compute all pairwise scalar products:
      result_type avg = 0;
      foreach(const ref x; xs)
        foreach(const ref y; xs)
          avg += scalar_product(x, y);
      avg /= N ^^ 2;// Replace manual multiplication with pow operator
      writeln("result: ", avg);
      countElapsed("scalar products");

      return 0;
    }

J'ai compilé chacun de ces tests à l'aide d'un compilateur basé sur LLVM, car LDC semble être la meilleure option pour la compilation D en termes de performances. Sur mon installation Linux Arch x86_64, j'ai utilisé les packages suivants:

  • clang 3.6.0-3
  • ldc 1:0.15.1-4
  • dtools 2.067.0-2

J'ai utilisé les commandes suivantes pour compiler chacun:

  • C ++: clang++ scalar.cpp -o"scalar.cpp.exe" -std=c++11 -O3
  • RÉ: rdmd --compiler=ldc2 -O3 -boundscheck=off <sourcefile>

Résultats

Les résultats ( capture d'écran de la sortie brute de la console ) de chaque version de la source comme suit:

  1. scalar.cpp (C ++ d'origine):

    allocation: 2 ms
    
    random generation: 12 ms
    
    result: 29248300000
    
    time: 2582 ms

    C ++ définit la norme à 2582 ms .

  2. scalar.d (source OP modifiée):

    allocation: 5 ms, 293 μs, and 5 hnsecs 
    
    random: 10 ms, 866 μs, and 4 hnsecs 
    
    result: 53237080000
    
    scalar products: 2 secs, 956 ms, 513 μs, and 7 hnsecs 

    Cela a duré ~ 2957 ms . Plus lent que l'implémentation C ++, mais pas trop.

  3. scalar2.d (changement de type d'index / longueur et optimisation de tableau non initialisé):

    allocation: 2 ms, 464 μs, and 2 hnsecs
    
    random: 5 ms, 792 μs, and 6 hnsecs
    
    result: 59
    
    scalar products: 1 sec, 859 ms, 942 μs, and 9 hnsecs

    En d'autres termes, ~ 1860 ms . Jusqu'à présent, c'est en tête.

  4. scalar3.d (foreaches):

    allocation: 2 ms, 911 μs, and 3 hnsecs
    
    random: 7 ms, 567 μs, and 8 hnsecs
    
    result: 189
    
    scalar products: 2 secs, 182 ms, and 366 μs

    ~ 2182 ms est plus lent scalar2.dque la version C ++, mais plus rapide.

Conclusion

Avec les optimisations correctes, l'implémentation D est en fait allée plus vite que son implémentation C ++ équivalente en utilisant les compilateurs LLVM disponibles. L'écart actuel entre D et C ++ pour la plupart des applications semble être uniquement basé sur les limitations des implémentations actuelles.


8

dmd est l'implémentation de référence du langage et donc la plupart du travail est mis dans le frontend pour corriger les bogues plutôt que pour optimiser le backend.

"in" est plus rapide dans votre cas car vous utilisez des tableaux dynamiques qui sont des types de référence. Avec ref, vous introduisez un autre niveau d'indirection (qui est normalement utilisé pour modifier le tableau lui-même et pas seulement le contenu).

Les vecteurs sont généralement implémentés avec des structures où const ref est parfaitement logique. Voir smallptD vs smallpt pour un exemple du monde réel présentant de nombreuses opérations vectorielles et le caractère aléatoire.

Notez que 64 bits peut également faire la différence. J'ai manqué une fois que sur x64, gcc compile le code 64 bits tandis que dmd est toujours par défaut à 32 (changera lorsque le codegen 64 bits mûrit). Il y avait une accélération remarquable avec "dmd -m64 ...".


7

Le fait que C ++ ou D soit plus rapide dépendra probablement fortement de ce que vous faites. Je pense qu'en comparant du C ++ bien écrit à du code D bien écrit, ils seraient généralement soit de vitesse similaire, soit C ++ serait plus rapide, mais ce que le compilateur particulier parvient à optimiser pourrait avoir un effet important en dehors du langage lui-même.

Cependant, il existe quelques cas où D a de bonnes chances de battre C ++ pour la vitesse. Le principal qui me vient à l'esprit serait le traitement des chaînes. Grâce aux capacités de découpage des tableaux de D, les chaînes (et les tableaux en général) peuvent être traitées beaucoup plus rapidement que vous ne pouvez le faire facilement en C ++. Pour D1, le processeur XML de Tango est extrêmement rapide , principalement grâce aux capacités de découpage de tableau de D (et j'espère que D2 aura un analyseur XML aussi rapide une fois celui sur lequel on travaille actuellement pour Phobos). Donc, en fin de compte, que D ou C ++ soit plus rapide dépendra beaucoup de ce que vous faites.

Maintenant, je suis surpris que vous voyiez une telle différence de vitesse dans ce cas particulier, mais c'est le genre de chose que je m'attendrais à améliorer à mesure que dmd s'améliore. L'utilisation de gdc pourrait donner de meilleurs résultats et constituerait probablement une comparaison plus étroite du langage lui-même (plutôt que du backend) étant donné qu'il est basé sur gcc. Mais cela ne me surprendrait pas du tout s'il y a un certain nombre de choses qui pourraient être faites pour accélérer le code généré par dmd. Je ne pense pas qu'il y ait beaucoup de doute sur le fait que gcc est plus mature que dmd à ce stade. Et les optimisations de code sont l'un des principaux fruits de la maturité du code.

En fin de compte, ce qui compte, c'est la performance de dmd pour votre application particulière, mais je suis d'accord qu'il serait certainement bien de savoir à quel point C ++ et D se comparent en général. En théorie, ils devraient être à peu près les mêmes, mais cela dépend vraiment de la mise en œuvre. Je pense qu'un ensemble complet de points de repère serait toutefois nécessaire pour vraiment tester la façon dont les deux se comparent actuellement.


4
Oui, je serais surpris si l'entrée / sortie était significativement plus rapide dans l'une ou l'autre langue, ou si les mathématiques pures étaient significativement plus rapides dans l'une ou l'autre langue, mais les opérations sur les chaînes, la gestion de la mémoire et quelques autres choses pourraient facilement faire briller une langue.
Max Lybbert

1
Il est facile de faire mieux (plus vite) que les iostreams C ++. Mais c'est principalement un problème d'implémentation de bibliothèque (sur toutes les versions connues des fournisseurs les plus populaires).
Ben Voigt

4

Vous pouvez écrire du code C est D donc dans la mesure où ce qui est le plus rapide, cela dépendra de beaucoup de choses:

  • Quel compilateur vous utilisez
  • Quelle fonctionnalité vous utilisez
  • avec quelle agressivité vous optimisez

Les différences dans le premier ne sont pas justes à faire glisser. Le second pourrait donner un avantage à C ++ car il a, le cas échéant, moins de fonctionnalités lourdes. Le troisième est le plus amusant: le code D est à certains égards plus facile à optimiser car en général il est plus facile à comprendre. En outre, il a la capacité de faire un grand degré de programmation générative permettant à des choses comme du code verbeux et répétitif mais rapide d'être écrit dans des formes plus courtes.


3

Cela semble être un problème de qualité de mise en œuvre. Par exemple, voici ce que j'ai testé avec:

import std.datetime, std.stdio, std.random;

version = ManualInline;

immutable N = 20000;
immutable Size = 10;

alias int value_type;
alias long result_type;
alias value_type[] vector_type;

result_type scalar_product(in vector_type x, in vector_type y)
in
{
    assert(x.length == y.length);
}
body
{
    result_type result = 0;

    foreach(i; 0 .. x.length)
        result += x[i] * y[i];

    return result;
}

void main()
{   
    auto startTime = Clock.currTime();

    // 1. allocate vectors
    vector_type[] vectors = new vector_type[N];
    foreach(ref vec; vectors)
        vec = new value_type[Size];

    auto time = Clock.currTime() - startTime;
    writefln("allocation: %s ", time);
    startTime = Clock.currTime();

    // 2. randomize vectors
    foreach(ref vec; vectors)
        foreach(ref e; vec)
            e = uniform(-1000, 1000);

    time = Clock.currTime() - startTime;
    writefln("random: %s ", time);
    startTime = Clock.currTime();

    // 3. compute all pairwise scalar products
    result_type avg = 0;

    foreach(vecA; vectors)
        foreach(vecB; vectors)
        {
            version(ManualInline)
            {
                result_type result = 0;

                foreach(i; 0 .. vecA.length)
                    result += vecA[i] * vecB[i];

                avg += result;
            }
            else
            {
                avg += scalar_product(vecA, vecB);
            }
        }

    avg = avg / (N * N);

    time = Clock.currTime() - startTime;
    writefln("scalar products: %s ", time);
    writefln("result: %s", avg);
}

Avec ManualInlinedéfini, j'obtiens 28 secondes, mais sans 32. Donc, le compilateur n'intègre même pas cette fonction simple, ce qui, à mon avis, devrait l'être.

(Ma ligne de commande est dmd -O -noboundscheck -inline -release ....)


1
Vos timings n'ont aucun sens à moins que vous ne donniez également la comparaison avec vos timings C ++.
deceleratedcaviar

3
@Daniel: Vous manquez le point. C'était pour démontrer les optimisations D de manière isolée, à savoir pour la conclusion que j'ai déclaré: "Donc, le compilateur n'intègre même pas cette fonction simple, ce qui, à mon avis, devrait l'être." J'essaye même de le comparer au C ++, comme je l'ai clairement indiqué dans la première phrase: "Cela semble être un problème de qualité d'implémentation."
GManNickG

Ah vrai, désolé :). Vous constaterez également que le compilateur DMD ne vectorise pas du tout les boucles.
deceleratedcaviar
En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.