web-dev-qa-db-fra.com

Quand optimiser la mémoire par rapport à la vitesse de performance d'une méthode?

J'ai récemment interviewé sur Amazon. Lors d'une session de codage, l'intervieweur m'a demandé pourquoi j'avais déclaré une variable dans une méthode. J'ai expliqué mon processus et il m'a mis au défi de résoudre le même problème avec moins de variables. Par exemple (ce n'était pas de l'interview), j'ai commencé avec Méthode A puis amélioré à Méthode B, en supprimant int s. Il était satisfait et a déclaré que cela réduirait l'utilisation de la mémoire par cette méthode.

Je comprends la logique derrière cela, mais ma question est:

Quand est-il approprié d'utiliser la méthode A contre la méthode B, et vice versa?

Vous pouvez voir que la méthode A va avoir une utilisation de mémoire plus élevée, car int s est déclaré, mais il ne doit effectuer qu'un seul calcul, c'est-à-dire a + b. D'un autre côté, La méthode B a une utilisation moindre de la mémoire, mais doit effectuer deux calculs, c'est-à-dire a + b deux fois. Quand dois-je utiliser une technique par rapport à l'autre? Ou bien, l'une des techniques est-elle toujours préférée à l'autre? Quelles sont les choses à considérer lors de l'évaluation des deux méthodes?

Méthode A:

private bool IsSumInRange(int a, int b)
{
    int s = a + b;

    if (s > 1000 || s < -1000) return false;
    else return true;
}

Méthode B:

private bool IsSumInRange(int a, int b)
{
    if (a + b > 1000 || a + b < -1000) return false;
    else return true;
}
109
Corey P

Au lieu de spéculer sur ce qui peut ou ne peut pas arriver, regardons simplement, d'accord? Je vais devoir utiliser C++ car je n'ai pas de compilateur C # à portée de main (bien que voir l'exemple C # de VisualMelon ), mais je suis sûr que les mêmes principes s'appliquent indépendamment.

Nous inclurons les deux alternatives que vous avez rencontrées dans l'entretien. Nous inclurons également une version qui utilise abs comme le suggèrent certaines réponses.

#include <cstdlib>

bool IsSumInRangeWithVar(int a, int b)
{
    int s = a + b;

    if (s > 1000 || s < -1000) return false;
    else return true;
}

bool IsSumInRangeWithoutVar(int a, int b)
{
    if (a + b > 1000 || a + b < -1000) return false;
    else return true;
}

bool IsSumInRangeSuperOptimized(int a, int b) {
    return (abs(a + b) < 1000);
}

Maintenant, compilez-le sans aucune optimisation: g++ -c -o test.o test.cpp

Maintenant, nous pouvons voir précisément ce que cela génère: objdump -d test.o

0000000000000000 <_Z19IsSumInRangeWithVarii>:
   0:   55                      Push   %rbp              # begin a call frame
   1:   48 89 e5                mov    %rsp,%rbp
   4:   89 7d ec                mov    %edi,-0x14(%rbp)  # save first argument (a) on stack
   7:   89 75 e8                mov    %esi,-0x18(%rbp)  # save b on stack
   a:   8b 55 ec                mov    -0x14(%rbp),%edx  # load a and b into edx
   d:   8b 45 e8                mov    -0x18(%rbp),%eax  # load b into eax
  10:   01 d0                   add    %edx,%eax         # add a and b
  12:   89 45 fc                mov    %eax,-0x4(%rbp)   # save result as s on stack
  15:   81 7d fc e8 03 00 00    cmpl   $0x3e8,-0x4(%rbp) # compare s to 1000
  1c:   7f 09                   jg     27                # jump to 27 if it's greater
  1e:   81 7d fc 18 fc ff ff    cmpl   $0xfffffc18,-0x4(%rbp) # compare s to -1000
  25:   7d 07                   jge    2e                # jump to 2e if it's greater or equal
  27:   b8 00 00 00 00          mov    $0x0,%eax         # put 0 (false) in eax, which will be the return value
  2c:   eb 05                   jmp    33 <_Z19IsSumInRangeWithVarii+0x33>
  2e:   b8 01 00 00 00          mov    $0x1,%eax         # put 1 (true) in eax
  33:   5d                      pop    %rbp
  34:   c3                      retq

0000000000000035 <_Z22IsSumInRangeWithoutVarii>:
  35:   55                      Push   %rbp
  36:   48 89 e5                mov    %rsp,%rbp
  39:   89 7d fc                mov    %edi,-0x4(%rbp)
  3c:   89 75 f8                mov    %esi,-0x8(%rbp)
  3f:   8b 55 fc                mov    -0x4(%rbp),%edx
  42:   8b 45 f8                mov    -0x8(%rbp),%eax  # same as before
  45:   01 d0                   add    %edx,%eax
  # note: unlike other implementation, result is not saved
  47:   3d e8 03 00 00          cmp    $0x3e8,%eax      # compare to 1000
  4c:   7f 0f                   jg     5d <_Z22IsSumInRangeWithoutVarii+0x28>
  4e:   8b 55 fc                mov    -0x4(%rbp),%edx  # since s wasn't saved, load a and b from the stack again
  51:   8b 45 f8                mov    -0x8(%rbp),%eax
  54:   01 d0                   add    %edx,%eax
  56:   3d 18 fc ff ff          cmp    $0xfffffc18,%eax # compare to -1000
  5b:   7d 07                   jge    64 <_Z22IsSumInRangeWithoutVarii+0x2f>
  5d:   b8 00 00 00 00          mov    $0x0,%eax
  62:   eb 05                   jmp    69 <_Z22IsSumInRangeWithoutVarii+0x34>
  64:   b8 01 00 00 00          mov    $0x1,%eax
  69:   5d                      pop    %rbp
  6a:   c3                      retq

000000000000006b <_Z26IsSumInRangeSuperOptimizedii>:
  6b:   55                      Push   %rbp
  6c:   48 89 e5                mov    %rsp,%rbp
  6f:   89 7d fc                mov    %edi,-0x4(%rbp)
  72:   89 75 f8                mov    %esi,-0x8(%rbp)
  75:   8b 55 fc                mov    -0x4(%rbp),%edx
  78:   8b 45 f8                mov    -0x8(%rbp),%eax
  7b:   01 d0                   add    %edx,%eax
  7d:   3d 18 fc ff ff          cmp    $0xfffffc18,%eax
  82:   7c 16                   jl     9a <_Z26IsSumInRangeSuperOptimizedii+0x2f>
  84:   8b 55 fc                mov    -0x4(%rbp),%edx
  87:   8b 45 f8                mov    -0x8(%rbp),%eax
  8a:   01 d0                   add    %edx,%eax
  8c:   3d e8 03 00 00          cmp    $0x3e8,%eax
  91:   7f 07                   jg     9a <_Z26IsSumInRangeSuperOptimizedii+0x2f>
  93:   b8 01 00 00 00          mov    $0x1,%eax
  98:   eb 05                   jmp    9f <_Z26IsSumInRangeSuperOptimizedii+0x34>
  9a:   b8 00 00 00 00          mov    $0x0,%eax
  9f:   5d                      pop    %rbp
  a0:   c3                      retq

Nous pouvons voir à partir des adresses de pile (par exemple, le -0x4 Dans mov %edi,-0x4(%rbp) par rapport au -0x14 Dans mov %edi,-0x14(%rbp)) que IsSumInRangeWithVar() utilise 16 octets supplémentaires sur la pile.

Étant donné que IsSumInRangeWithoutVar() n'alloue aucun espace sur la pile pour stocker la valeur intermédiaire s, elle doit la recalculer, ce qui entraîne une implémentation de 2 instructions de plus.

Drôle, IsSumInRangeSuperOptimized() ressemble beaucoup à IsSumInRangeWithoutVar(), sauf qu'il se compare à -1000 en premier et à 1000 secondes.

Compilons maintenant avec seulement les optimisations les plus élémentaires: g++ -O1 -c -o test.o test.cpp. Le résultat:

0000000000000000 <_Z19IsSumInRangeWithVarii>:
   0:   8d 84 37 e8 03 00 00    lea    0x3e8(%rdi,%rsi,1),%eax
   7:   3d d0 07 00 00          cmp    $0x7d0,%eax
   c:   0f 96 c0                setbe  %al
   f:   c3                      retq

0000000000000010 <_Z22IsSumInRangeWithoutVarii>:
  10:   8d 84 37 e8 03 00 00    lea    0x3e8(%rdi,%rsi,1),%eax
  17:   3d d0 07 00 00          cmp    $0x7d0,%eax
  1c:   0f 96 c0                setbe  %al
  1f:   c3                      retq

0000000000000020 <_Z26IsSumInRangeSuperOptimizedii>:
  20:   8d 84 37 e8 03 00 00    lea    0x3e8(%rdi,%rsi,1),%eax
  27:   3d d0 07 00 00          cmp    $0x7d0,%eax
  2c:   0f 96 c0                setbe  %al
  2f:   c3                      retq

Souhaitez-vous regarder cela: chaque variante est identique . Le compilateur est capable de faire quelque chose d'assez intelligent: abs(a + b) <= 1000 est équivalent à a + b + 1000 <= 2000 Considérant que setbe fait une comparaison non signée, donc un nombre négatif devient un très grand nombre positif. L'instruction lea peut réellement effectuer tous ces ajouts dans une seule instruction et éliminer toutes les branches conditionnelles.

Pour répondre à votre question, presque toujours la chose à optimiser n'est pas la mémoire ou la vitesse, mais la lisibilité . La lecture de code est beaucoup plus difficile que de l'écrire, et la lecture de code qui a été modifié pour "l'optimiser" est beaucoup plus difficile que la lecture de code qui a été écrit pour être clair. Plus souvent qu'autrement, ces "optimisations" ont un impact réel négligeable, ou dans ce cas exactement nul sur les performances.


Question de suivi, qu'est-ce qui change quand ce code est dans un langage interprété au lieu d'être compilé? Alors, l'optimisation est-elle importante ou a-t-elle le même résultat?

Mesurons! J'ai transcrit les exemples en Python:

def IsSumInRangeWithVar(a, b):
    s = a + b
    if s > 1000 or s < -1000:
        return False
    else:
        return True

def IsSumInRangeWithoutVar(a, b):
    if a + b > 1000 or a + b < -1000:
        return False
    else:
        return True

def IsSumInRangeSuperOptimized(a, b):
    return abs(a + b) <= 1000

from dis import dis
print('IsSumInRangeWithVar')
dis(IsSumInRangeWithVar)

print('\nIsSumInRangeWithoutVar')
dis(IsSumInRangeWithoutVar)

print('\nIsSumInRangeSuperOptimized')
dis(IsSumInRangeSuperOptimized)

print('\nBenchmarking')
import timeit
print('IsSumInRangeWithVar: %fs' % (min(timeit.repeat(lambda: IsSumInRangeWithVar(42, 42), repeat=50, number=100000)),))
print('IsSumInRangeWithoutVar: %fs' % (min(timeit.repeat(lambda: IsSumInRangeWithoutVar(42, 42), repeat=50, number=100000)),))
print('IsSumInRangeSuperOptimized: %fs' % (min(timeit.repeat(lambda: IsSumInRangeSuperOptimized(42, 42), repeat=50, number=100000)),))

Exécutez avec Python 3.5.2, cela produit la sortie:

IsSumInRangeWithVar
  2           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 BINARY_ADD
              7 STORE_FAST               2 (s)

  3          10 LOAD_FAST                2 (s)
             13 LOAD_CONST               1 (1000)
             16 COMPARE_OP               4 (>)
             19 POP_JUMP_IF_TRUE        34
             22 LOAD_FAST                2 (s)
             25 LOAD_CONST               4 (-1000)
             28 COMPARE_OP               0 (<)
             31 POP_JUMP_IF_FALSE       38

  4     >>   34 LOAD_CONST               2 (False)
             37 RETURN_VALUE

  6     >>   38 LOAD_CONST               3 (True)
             41 RETURN_VALUE
             42 LOAD_CONST               0 (None)
             45 RETURN_VALUE

IsSumInRangeWithoutVar
  9           0 LOAD_FAST                0 (a)
              3 LOAD_FAST                1 (b)
              6 BINARY_ADD
              7 LOAD_CONST               1 (1000)
             10 COMPARE_OP               4 (>)
             13 POP_JUMP_IF_TRUE        32
             16 LOAD_FAST                0 (a)
             19 LOAD_FAST                1 (b)
             22 BINARY_ADD
             23 LOAD_CONST               4 (-1000)
             26 COMPARE_OP               0 (<)
             29 POP_JUMP_IF_FALSE       36

 10     >>   32 LOAD_CONST               2 (False)
             35 RETURN_VALUE

 12     >>   36 LOAD_CONST               3 (True)
             39 RETURN_VALUE
             40 LOAD_CONST               0 (None)
             43 RETURN_VALUE

IsSumInRangeSuperOptimized
 15           0 LOAD_GLOBAL              0 (abs)
              3 LOAD_FAST                0 (a)
              6 LOAD_FAST                1 (b)
              9 BINARY_ADD
             10 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             13 LOAD_CONST               1 (1000)
             16 COMPARE_OP               1 (<=)
             19 RETURN_VALUE

Benchmarking
IsSumInRangeWithVar: 0.019361s
IsSumInRangeWithoutVar: 0.020917s
IsSumInRangeSuperOptimized: 0.020171s

Le démontage en Python n'est pas terriblement intéressant, car le "compilateur" de bytecode ne fait pas grand-chose en termes d'optimisation.

Les performances des trois fonctions sont presque identiques. Nous pourrions être tentés d'utiliser IsSumInRangeWithVar() en raison de son gain de vitesse marginal. Bien que j'ajouterai car j'essayais différents paramètres pour timeit, parfois IsSumInRangeSuperOptimized() sortait le plus rapidement, donc je soupçonne que ce peuvent être des facteurs externes responsables de la différence, plutôt que tout avantage intrinsèque de toute mise en œuvre.

S'il s'agit vraiment d'un code critique pour les performances, un langage interprété est tout simplement un très mauvais choix. En exécutant le même programme avec pypy, j'obtiens:

IsSumInRangeWithVar: 0.000180s
IsSumInRangeWithoutVar: 0.001175s
IsSumInRangeSuperOptimized: 0.001306s

Le simple fait d'utiliser pypy, qui utilise la compilation JIT pour éliminer une grande partie de la surcharge de l'interpréteur, a permis d'améliorer les performances de 1 ou 2 ordres de grandeur. J'ai été assez choqué de voir que IsSumInRangeWithVar() est un ordre de grandeur plus rapide que les autres. J'ai donc changé l'ordre des benchmarks et j'ai recommencé:

IsSumInRangeSuperOptimized: 0.000191s
IsSumInRangeWithoutVar: 0.001174s
IsSumInRangeWithVar: 0.001265s

Il semble donc que ce ne soit pas vraiment l'implémentation qui le rende rapide, mais plutôt l'ordre dans lequel je fais le benchmarking!

J'adorerais approfondir cela, car honnêtement, je ne sais pas pourquoi cela se produit. Mais je crois que le point a été avancé: les micro-optimisations comme déclarer ou non une valeur intermédiaire comme variable sont rarement pertinentes. Avec un langage interprété ou un compilateur hautement optimisé, le premier objectif reste d'écrire du code clair.

Si une optimisation supplémentaire peut être nécessaire, référence . N'oubliez pas que les meilleures optimisations ne viennent pas des petits détails mais de l'image algorithmique plus grande: pypy va être un ordre de grandeur plus rapide pour une évaluation répétée de la même fonction que cpython car il utilise des algorithmes plus rapides (compilateur JIT vs interprétation) pour évaluer la programme. Et il y a aussi l'algorithme codé à considérer: une recherche dans un arbre B sera plus rapide qu'une liste chaînée.

Après vous être assuré que vous utilisez les bons outils et algorithmes pour le travail, soyez prêt à plonger en profondeur dans les détails du système. Les résultats peuvent être très surprenants, même pour les développeurs expérimentés, et c'est pourquoi vous devez disposer d'une référence pour quantifier les changements.

147
Phil Frost

Pour répondre à la question posée:

Quand optimiser la mémoire par rapport à la vitesse de performance d'une méthode?

Il y a deux choses que vous devez établir:

  • Qu'est-ce qui limite votre candidature?
  • Où puis-je récupérer le plus de cette ressource?

Pour répondre à la première question, vous devez connaître les exigences de performances de votre application. S'il n'y a pas d'exigences de performances, il n'y a aucune raison d'optimiser d'une manière ou d'une autre. Les exigences de performance vous aident à vous rendre au lieu de "assez bien".

La méthode que vous avez fournie seule ne causerait pas de problèmes de performances à elle seule, mais peut-être dans une boucle et en traitant une grande quantité de données, vous devez commencer à penser un peu différemment à la façon dont vous abordez le problème.

Détecter ce qui limite l'application

Commencez à regarder le comportement de votre application avec un moniteur de performances. Gardez un œil sur l'utilisation du processeur, du disque, du réseau et de la mémoire pendant son fonctionnement. Un ou plusieurs éléments seront maximisés tandis que tout le reste est modérément utilisé - sauf si vous atteignez l'équilibre parfait, mais cela n'arrive presque jamais).

Lorsque vous devez regarder plus en profondeur, vous utilisez généralement un profileur . Il existe profileurs de mémoire et profileurs de processus , et ils mesurent différentes choses. L'acte de profilage a un impact significatif sur les performances, mais vous instrumentez votre code pour découvrir ce qui ne va pas.

Supposons que votre utilisation du processeur et du disque ait atteint un sommet. Vous devriez d'abord vérifier les "points chauds" ou le code qui est appelé plus souvent que les autres ou qui prend un pourcentage beaucoup plus long du traitement.

Si vous ne trouvez aucun point chaud, vous commencerez alors à regarder la mémoire. Peut-être que vous créez plus d'objets que nécessaire et que votre récupération de place fait des heures supplémentaires.

Récupération des performances

Pense de façon critique. La liste suivante des modifications est en fonction du retour sur investissement que vous obtiendrez:

  • Architecture: rechercher les points d'étranglement de la communication
  • Algorithme: la façon dont vous traitez les données pourrait devoir changer
  • Points chauds: minimiser la fréquence à laquelle vous appelez le point chaud peut générer un gros bonus
  • Micro optimisations: ce n'est pas courant, mais parfois vous avez vraiment besoin de penser à des ajustements mineurs (comme l'exemple que vous avez fourni), en particulier s'il s'agit d'un point chaud dans votre code.

Dans de telles situations, vous devez appliquer la méthode scientifique. Trouvez une hypothèse, apportez les modifications et testez-la. Si vous atteignez vos objectifs de performance, vous avez terminé. Sinon, passez à la chose suivante dans la liste.


Répondre à la question en gras:

Quand est-il approprié d'utiliser la méthode A contre la méthode B, et vice versa?

Honnêtement, c'est la dernière étape pour essayer de résoudre les problèmes de performances ou de mémoire. L'impact de la méthode A par rapport à la méthode B sera vraiment différent selon la langue et plateforme (dans certains cas).

À peu près n'importe quel langage compilé avec un optimiseur décent à mi-chemin générera un code similaire avec l'une ou l'autre de ces structures. Cependant, ces hypothèses ne restent pas nécessairement vraies dans les langages propriétaires et jouets qui n'ont pas d'optimiseur.

Précisément, ce qui aura un meilleur impact dépend si sum est une variable de pile ou une variable de tas. Il s'agit d'un choix d'implémentation du langage. En C, C++ et Java par exemple, les primitives numériques comme un int sont des variables de pile par défaut. Votre code n'a pas plus d'impact sur la mémoire en l'affectant à une variable de pile que vous ne le feriez avoir avec un code entièrement en ligne.

Une autre optimisation dépendante de la plate-forme est une autre optimisation que vous pourriez trouver dans les bibliothèques C (en particulier les plus anciennes) où vous pouvez avoir à choisir entre copier un tableau bidimensionnel en premier ou en premier. Cela nécessite une certaine connaissance de la façon dont le chipset que vous ciblez optimise le mieux l'accès à la mémoire. Il existe de subtiles différences entre les architectures.

L'essentiel est que l'optimisation est une combinaison d'art et de science. Cela nécessite une réflexion critique, ainsi qu'une certaine souplesse dans la façon dont vous abordez le problème. Recherchez les grandes choses avant de blâmer les petites choses.

66
Berin Loritsch

"cela réduirait la mémoire" - em, non. Même si cela était vrai (ce qui n'est pas le cas pour tout compilateur décent), la différence serait très probablement négligeable pour toute situation réelle.

Cependant, je recommanderais d'utiliser la méthode A * (méthode A avec un léger changement):

private bool IsSumInRange(int a, int b)
{
    int sum = a + b;

    if (sum > 1000 || sum < -1000) return false;
    else return true;
    // (yes, the former statement could be cleaned up to
    // return abs(sum)<=1000;
    // but let's ignore this for a moment)
}

mais pour deux raisons complètement différentes:

  • en donnant à la variable s un nom explicatif, le code devient plus clair

  • cela évite d'avoir deux fois la même logique de sommation dans le code, de sorte que le code devient plus SEC, ce qui signifie moins d'erreurs sujettes aux changements.

45
Doc Brown

Vous pouvez faire mieux que les deux

return (abs(a + b) > 1000);

La plupart des processeurs (et donc des compilateurs) peuvent faire abs () en une seule opération. Vous avez non seulement moins de sommes, mais aussi moins de comparaisons, qui sont généralement plus coûteuses en termes de calcul. Il supprime également la ramification, ce qui est bien pire sur la plupart des processeurs car il empêche le pipelining.

L'enquêteur, comme d'autres réponses l'ont dit, porte sur la vie végétale et n'a aucune activité à mener une entrevue technique.

Cela dit, sa question est valable. Et la réponse à quand vous optimisez et comment, est lorsque vous avez prouvé que c'est nécessaire, et que vous l'avez profilé pour prouver exactement quelles pièces en ont besoin . Knuth a déclaré que l'optimisation prématurée est la racine de tout mal, car il est trop facile d'essayer de plaquer l'or des sections sans importance, ou d'apporter des modifications (comme votre intervieweur) qui n'ont aucun effet, tout en manquant les endroits qui en ont vraiment besoin. Jusqu'à ce que vous ayez une preuve tangible que c'est vraiment nécessaire, la clarté du code est la cible la plus importante.

Edit FabioTurati souligne correctement que c'est le sens logique opposé à l'original, (mon erreur!), Et que cela illustre un autre impact de la citation de Knuth où nous risquons de casser le code pendant que nous essayons pour l'optimiser.

33
Graham

Quand est-il approprié d'utiliser la méthode A contre la méthode B, et vice versa?

Le matériel est bon marché; les programmeurs sont chers . Donc, le coût du temps que vous avez perdu sur cette question est probablement bien pire que l'une ou l'autre réponse.

Quoi qu'il en soit, la plupart des compilateurs modernes trouveraient un moyen d'optimiser la variable locale dans un registre (au lieu d'allouer de l'espace de pile), donc les méthodes sont probablement identiques en termes de code exécutable. Pour cette raison, la plupart des développeurs choisiraient l'option qui communique l'intention le plus clairement (voir Écrire un code vraiment évident (ROC) ). À mon avis, ce serait la méthode A.

D'un autre côté, si c'est un exercice purement académique, vous pouvez avoir le meilleur des deux mondes avec la méthode C:

private bool IsSumInRange(int a, int b)
{
    a += b;
    return (a >= -1000 && a <= 1000);
}
16
John Wu

J'optimiserais pour la lisibilité. Méthode X:

private bool IsSumInRange(int number1, int number2)
{
    return IsValueInRange(number1+number2, -1000, 1000);
}

private bool IsValueInRange(int Value, int Lowerbound, int Upperbound)
{
    return  (Value >= Lowerbound && Value <= Upperbound);
}

Petites méthodes qui ne font qu'une chose mais sont faciles à raisonner.

(C'est une préférence personnelle, j'aime les tests positifs plutôt que négatifs, votre code d'origine teste en fait si la valeur n'est PAS en dehors de la plage.)

11
Pieter B

En bref, je ne pense pas que la question ait beaucoup de pertinence dans l'informatique actuelle, mais d'un point de vue historique, c'est un exercice de réflexion intéressant.

Votre intervieweur est probablement un fan du Mois de l'homme mythique. Dans le livre, Fred Brooks fait valoir que les programmeurs auront généralement besoin de deux versions de fonctions clés dans leur boîte à outils: une version optimisée en mémoire et une version optimisée en CPU. Fred a basé cela sur son expérience en dirigeant le développement du système d'exploitation IBM System/360 où les machines peuvent avoir aussi peu que 8 kilo-octets de RAM. Dans de telles machines, la mémoire requise pour les variables locales dans les fonctions pourrait être potentiellement importante, surtout si le compilateur ne les optimisait pas efficacement (ou si le code était écrit directement en langage assembleur).

À l'ère actuelle, je pense que vous auriez du mal à trouver un système où la présence ou l'absence d'une variable locale dans une méthode ferait une différence notable. Pour qu'une variable importe, la méthode doit être récursive avec une récursion profonde attendue. Même alors, il est probable que la profondeur de pile soit dépassée, provoquant des exceptions de dépassement de pile avant que la variable elle-même ne cause un problème. Le seul vrai scénario où cela peut être un problème est avec de très grands tableaux alloués sur la pile dans une méthode récursive. Mais cela est également peu probable, car je pense que la plupart des développeurs réfléchiraient à deux fois aux copies inutiles de grands tableaux.

6
Eric

Après l'affectation s = a + b; les variables a et b ne sont plus utilisées. Par conséquent, aucune mémoire n'est utilisée pour s si vous n'utilisez pas un compilateur complètement endommagé par le cerveau; la mémoire utilisée de toute façon pour a et b est réutilisée.

Mais optimiser cette fonction est un non-sens absolu. Si vous pouviez économiser de l'espace, ce serait peut-être 8 octets pendant que la fonction est en cours d'exécution (ce qui est récupéré lorsque la fonction revient), donc absolument inutile. Si vous pouviez gagner du temps, ce serait un nombre unique de nanosecondes. L'optimisation est une perte de temps totale.

4
gnasher729

Les variables de type valeur locale sont allouées sur la pile ou (plus probablement pour de si petits morceaux de code) utilisent des registres dans le processeur et ne voient jamais de RAM. De toute façon, ils sont de courte durée et rien à craindre. Vous commencez à envisager l'utilisation de la mémoire lorsque vous devez mettre en mémoire tampon ou mettre en file d'attente des éléments de données dans des collections potentiellement volumineuses et à longue durée de vie.

Ensuite, cela dépend de ce qui vous intéresse le plus pour votre application. Vitesse de traitement? Temps de réponse? Empreinte mémoire? Maintenabilité? Cohérence dans la conception? Tout dépend de toi.

3
Martin Maat

Comme d'autres réponses l'ont dit, vous devez penser à ce que vous optimisez.

Dans cet exemple, je soupçonne que n'importe quel compilateur décent générerait du code équivalent pour les deux méthodes, donc la décision n'aurait aucun effet sur la mémoire d'exécution ou !

Ce que cela affecte , c'est la lisibilité du code. (Le code est destiné aux humains, pas seulement aux ordinateurs.) Il n'y a pas trop de différence entre les deux exemples; quand toutes les autres choses sont égales, je considère la brièveté comme une vertu, donc je choisirais probablement la méthode B. Mais toutes les autres choses sont rarement égales, et dans un cas plus complexe du monde réel, cela pourrait avoir un grand effet.

Choses à considérer:

  • L'expression intermédiaire a-t-elle des effets secondaires? S'il appelle des fonctions impures ou met à jour des variables, la duplication serait bien sûr une question d'exactitude, pas seulement de style.
  • Quelle est la complexité de l'expression intermédiaire? S'il effectue de nombreux calculs et/ou appelle des fonctions, le compilateur peut ne pas être en mesure de l'optimiser, ce qui affecte les performances. (Bien que, comme Knuth dit , "Nous devrions oublier les petites efficacités, disons environ 97% du temps".)
  • La variable intermédiaire a-t-elle une signification ? Pourrait-on lui donner un nom qui aide à expliquer ce qui se passe? Un nom court mais informatif pourrait mieux expliquer le code, tandis qu'un autre dénué de sens n'est qu'un bruit visuel.
  • Quelle est la longueur de l'expression intermédiaire? S'il est long, sa duplication pourrait rendre le code plus long et plus difficile à lire (surtout s'il force un saut de ligne); sinon, la duplication pourrait être plus courte dans l'ensemble.
2
gidds

Comme beaucoup de réponses l'ont souligné, tenter de régler cette fonction avec des compilateurs modernes ne fera aucune différence. Un optimiseur peut très probablement trouver la meilleure solution (vote positif pour la réponse qui a montré le code assembleur pour le prouver!). Vous avez déclaré que le code dans l'interview n'était pas exactement le code qu'on vous a demandé de comparer, alors peut-être que l'exemple réel est un peu plus logique.

Mais regardons à nouveau cette question: c'est une question d'entrevue. Donc, le vrai problème est, comment devez-vous y répondre en supposant que vous voulez essayer d'obtenir le poste?

Supposons également que l'intervieweur sait de quoi il parle et qu'il essaie simplement de voir ce que vous savez.

Je mentionnerais que, en ignorant l'optimiseur, le premier peut créer une variable temporaire sur la pile alors que le second ne le ferait pas, mais effectuerait le calcul deux fois. Par conséquent, le premier utilise plus de mémoire mais est plus rapide.

Vous pouvez mentionner que de toute façon, un calcul peut nécessiter une variable temporaire pour stocker le résultat (afin qu'il puisse être comparé), donc que vous nommiez cette variable ou non, cela ne fera aucune différence.

Je mentionnerais ensuite qu'en réalité le code serait optimisé et que le code machine équivalent le plus probable serait généré puisque toutes les variables sont locales. Cependant, cela dépend du compilateur que vous utilisez (il n'y a pas si longtemps que j'ai pu obtenir une amélioration des performances utile en déclarant une variable locale comme "finale" en Java).

Vous pourriez mentionner que la pile vit de toute façon dans sa propre page de mémoire, donc à moins que votre variable supplémentaire ne fasse déborder la page de la pile, elle n'allouera en réalité plus de mémoire. S'il déborde, il voudra cependant une toute nouvelle page.

Je mentionnerais qu'un exemple plus réaliste pourrait être le choix d'utiliser ou non un cache pour contenir les résultats de nombreux calculs et cela soulèverait une question de CPU vs mémoire.

Tout cela démontre que vous savez de quoi vous parlez.

Je laisserais la fin pour dire qu'il serait préférable de se concentrer sur la lisibilité à la place. Bien que cela soit vrai dans ce cas, dans le contexte de l'interview, il peut être interprété comme "Je ne sais pas pour les performances mais mon code se lit comme une histoire Janet et John ".

Ce que vous ne devriez pas faire, c'est trotter les déclarations fades habituelles sur la façon dont l'optimisation du code n'est pas nécessaire, n'optimisez pas avant d'avoir profilé le code (cela indique simplement que vous ne pouvez pas voir le mauvais code par vous-même), le matériel coûte moins cher que les programmeurs , et s'il vous plaît, s'il vous plaît, ne citez pas Knuth "prématuré bla bla ...".

La performance du code est un véritable problème dans de nombreuses organisations et de nombreuses organisations ont besoin de programmeurs qui la comprennent.

En particulier, avec des organisations comme Amazon, une partie du code a un énorme effet de levier. Un extrait de code peut être déployé sur des milliers de serveurs ou des millions d'appareils et peut être appelé des milliards de fois par jour chaque jour de l'année. Il peut y avoir des milliers d'extraits similaires. La différence entre un mauvais algorithme et un bon peut facilement être un facteur de mille. Faites les chiffres et multipliez tout cela: cela fait une différence. Le coût potentiel pour l'organisation d'un code non performant peut être très important, voire fatal, si un système manque de capacité.

De plus, beaucoup de ces organisations travaillent dans un environnement compétitif. Vous ne pouvez donc pas simplement dire à vos clients d'acheter un ordinateur plus gros si le logiciel de votre concurrent fonctionne déjà correctement sur le matériel dont ils disposent ou si le logiciel fonctionne sur un combiné mobile et qu'il ne peut pas être mis à niveau. Certaines applications sont particulièrement critiques en termes de performances (les jeux et les applications mobiles viennent à l'esprit) et peuvent vivre ou mourir en fonction de leur réactivité ou de leur vitesse.

J'ai personnellement plus de deux décennies travaillé sur de nombreux projets où les systèmes ont échoué ou étaient inutilisables en raison de problèmes de performances et j'ai été appelé pour optimiser ces systèmes et dans tous les cas, cela était dû à un mauvais code écrit par des programmeurs qui ne comprenaient pas l'impact de ce qu'ils écrivaient. De plus, ce n'est jamais un morceau de code, c'est toujours partout. Quand j'arrive, il est trop tard pour commencer à penser à la performance: le mal est fait.

La compréhension des performances du code est une bonne compétence à avoir de la même manière que la compréhension de l'exactitude et du style de code. Cela sort de la pratique. Les échecs de performances peuvent être aussi mauvais que les échecs fonctionnels. Si le système ne fonctionne pas, il ne fonctionne pas. Peu importe pourquoi. De même, les performances et les fonctionnalités qui ne sont jamais utilisées sont mauvaises.

Donc, si l'intervieweur vous pose des questions sur la performance, je recommanderais d'essayer de démontrer autant de connaissances que possible. Si la question vous semble mauvaise, expliquez poliment pourquoi vous pensez que ce ne serait pas un problème dans ce cas. Ne citez pas Knuth.

1
rghome

Quand optimiser la mémoire par rapport à la vitesse de performance d'une méthode?

Après avoir obtenu la fonctionnalité droite première. Ensuite la sélectivité se préoccupe des micro optimisations.


En tant que question d'entrevue concernant les optimisations, le code provoque la discussion habituelle mais manque l'objectif de niveau supérieur Le code est-il fonctionnellement correct?

C++ et C et d'autres considèrent le débordement de int comme un problème du a + b. Il n'est pas bien défini et C l'appelle comportement indéfini . Il n'est pas spécifié de "boucler" - même si c'est le comportement courant.

bool IsSumInRange(int a, int b) {
    int s = a + b;  // Overflow possible
    if (s > 1000 || s < -1000) return false;
    else return true;
}

Une telle fonction appelée IsSumInRange() devrait être bien définie et fonctionner correctement pour toutes les valeurs de int de a,b. Le a + b Brut ne l'est pas. Une solution C pourrait utiliser:

#define N 1000
bool IsSumInRange_FullRange(int a, int b) {
  if (a >= 0) {
    if (b > INT_MAX - a) return false;
  } else {
    if (b < INT_MIN - a) return false;
  }
  int sum = a + b;
  if (sum > N || sum < -N) return false;
  else return true;
}

Le code ci-dessus pourrait être optimisé en utilisant un type entier plus large que int, si disponible, comme ci-dessous ou en distribuant les tests sum > N, sum < -N Dans la fonction if (a >= 0) logique. Pourtant, de telles optimisations peuvent ne pas vraiment conduire à un code émis "plus rapidement" avec un compilateur intelligent ni valoir la maintenance supplémentaire d'être intelligent.

  long long sum a;
  sum += b;

Même l'utilisation de abs(sum) est sujette à des problèmes lorsque sum == INT_MIN.

De quel type de compilateur parlons-nous, et de quelle sorte de "mémoire"? Parce que dans votre exemple, en supposant un optimiseur raisonnable, l'expression a+b doit généralement être stocké dans un registre (une forme de mémoire) avant d'effectuer une telle arithmétique.

Donc, si nous parlons d'un compilateur stupide qui rencontre a+b deux fois, cela va allouer plus de registres (mémoire) dans votre exemple second, car votre premier exemple pourrait simplement stocker cette expression une fois dans un seul registre mappé à la variable locale, mais nous parlons à propos des compilateurs très stupides à ce stade ... à moins que vous ne travailliez avec un autre type de compilateur stupide qui empile toutes les variables, dans ce cas peut-être la première la provoquerait plus peine à optimiser que la seconde *.

Je veux toujours rayer cela et penser que le second est susceptible d'utiliser plus de mémoire avec un compilateur stupide même s'il est susceptible d'empiler les déversements, car il pourrait finir par allouer trois registres pour a+b et déversement a et b plus. Si nous parlons de l'optimiseur le plus primitif, capturer a+b à s "l'aidera" probablement à utiliser moins de registres/déversements de pile.

Tout cela est extrêmement spéculatif de manière plutôt idiote en l'absence de mesures/démontage et même dans les pires scénarios, ce n'est pas un cas "mémoire vs performances" (car même parmi les pires optimiseurs auxquels je puisse penser, nous ne parlons pas à propos de tout sauf de la mémoire temporaire comme la pile/le registre), c'est au mieux un cas de "performances", et parmi tous les optimiseurs raisonnables, les deux sont équivalents, et si l'un n'utilise pas un optimiseur raisonnable, pourquoi est-il obsédé par l'optimisation de nature microscopique mesures particulièrement absentes? C'est comme la sélection des instructions/l'allocation des registres. Une concentration au niveau de l'assemblage que je ne m'attendrais jamais à ce que quiconque cherche à rester productif ait en utilisant, disons, un interprète qui empile tout.

Quand optimiser la mémoire par rapport à la vitesse de performance d'une méthode?

Quant à cette question, si je peux la traiter plus largement, souvent je ne trouve pas les deux diamétralement opposés. Surtout si vos modèles d'accès sont séquentiels et compte tenu de la vitesse du cache du processeur, souvent une réduction du nombre d'octets traités séquentiellement pour des entrées non triviales se traduit (jusqu'à un certain point) par un labour plus rapide de ces données. Bien sûr, il y a des points de rupture où si les données sont beaucoup, beaucoup plus petites en échange de manière, beaucoup plus d'instructions, il pourrait être plus rapide de traiter séquentiellement sous une forme plus grande en échange de moins d'instructions.

Mais j'ai trouvé que de nombreux développeurs ont tendance à sous-estimer à quel point une réduction de l'utilisation de la mémoire dans ces types de cas peut se traduire par des réductions proportionnelles du temps passé à traiter. Il est très humainement intuitif de traduire les coûts de performance en instructions plutôt qu'en accès mémoire au point d'atteindre de gros LUTs dans une tentative vaine d'accélérer certains petits calculs, seulement pour trouver des performances dégradées avec l'accès mémoire supplémentaire.

Pour les cas d'accès séquentiel à travers un énorme tableau (ne parlant pas de variables scalaires locales comme dans votre exemple), je respecte la règle selon laquelle moins de mémoire à parcourir séquentiellement se traduit par de meilleures performances, en particulier lorsque le code résultant est plus simple qu'autrement, jusqu'à ce qu'il ne le fasse pas 't, jusqu'à ce que mes mesures et mon profileur me disent le contraire, et c'est important, de la même manière, je suppose que la lecture séquentielle d'un fichier binaire plus petit sur le disque serait plus rapide à parcourir qu'un plus gros (même si le plus petit nécessite plus d'instructions) ), jusqu'à ce que cette hypothèse ne s'applique plus à mes mesures.

0
Dragon Energy

Vous devez d'abord optimiser la correction.

Votre fonction échoue pour les valeurs d'entrée qui sont proches de Int.MaxValue:

int a = int.MaxValue - 200;
int b = int.MaxValue - 200;
bool inRange = test.IsSumInRangeA(a, b);

Cela renvoie vrai car la somme déborde à -400. La fonction ne fonctionne pas non plus pour a = int.MinValue + 200. (ajoute incorrectement à "400")

Nous ne saurons pas ce que l'intervieweur recherchait à moins qu'il ou elle ne sonnent, mais "le débordement est réel".

Dans une situation d'entrevue, posez des questions pour clarifier la portée du problème: Quelles sont les valeurs d'entrée maximales et minimales autorisées? Une fois que vous les avez, vous pouvez lever une exception si l'appelant soumet des valeurs en dehors de la plage. Ou (en C #), vous pouvez utiliser une section {} vérifiée, qui lèverait une exception en cas de débordement. Oui, c'est plus de travail et compliqué, mais parfois c'est ce qu'il faut.

0
TomEberhard

Votre question aurait dû être: "Dois-je vraiment l'optimiser?".

Les versions A et B diffèrent dans un détail important qui rend A préférable, mais cela n'est pas lié à l'optimisation: vous ne répétez pas le code.

L '"optimisation" réelle est appelée élimination de la sous-expression commune, ce que font pratiquement tous les compilateurs. Certains effectuent cette optimisation de base même lorsque les optimisations sont désactivées. Ce n'est donc pas vraiment une optimisation (le code généré sera presque certainement exactement le même dans tous les cas).

Mais si ce n'est pas une optimisation, alors pourquoi est-il préférable? D'accord, vous ne répétez pas le code, peu importe!

Eh bien tout d'abord, vous ne risquez pas de vous tromper accidentellement la moitié de la clause conditionnelle. Mais plus important encore, quelqu'un qui lit ce code peut comprendre immédiatement ce que vous essayez de faire, au lieu d'une expérience if((((wtf||is||this||longexpression)))). Ce que le lecteur voit, c'est if(one || theother), ce qui est une bonne chose. Pas rarement, il m'arrive que vous êtes cette autre personne lisant votre propre code trois ans plus tard et pensant "WTF cela signifie-t-il?". Dans ce cas, il est toujours utile que votre code communique immédiatement quelle était l'intention. Avec une sous-expression commune nommée correctement, c'est le cas.
Aussi, si à tout moment dans le futur, vous décidez que par exemple vous devez changer a+b en a-b, vous devez changer un emplacement, pas deux. Et il n'y a aucun risque de (encore) se tromper par erreur.

Concernant votre question réelle, ce que vous devez optimiser, tout d'abord votre code doit être correct . C'est la chose la plus importante. Un code qui n'est pas correct est un mauvais code, même plus si, même s'il est incorrect, il "fonctionne bien", ou au moins il ressemble il fonctionne bien. Après cela, le code devrait être lisible (lisible par quelqu'un qui ne le connaît pas).
Quant à l'optimisation ... il ne faut certainement pas écrire délibérément du code anti-optimisé, et certainement je ne dis pas que vous ne devriez pas réfléchir à la conception avant de commencer (comme choisir le bon algorithme pour le problème, pas le moins efficace).

Mais pour la plupart des applications, la plupart du temps, les performances que vous obtenez après avoir exécuté un code correct et lisible en utilisant un algorithme raisonnable via un compilateur d'optimisation sont très bien, il n'y a pas vraiment lieu de s'inquiéter.

Si ce n'est pas le cas, c'est-à-dire si les performances de l'application ne répondent pas aux exigences, et alors seulement , vous devriez vous inquiéter optimisations comme celle que vous avez tentée. De préférence, cependant, vous reconsidérerez l'algorithme de niveau supérieur. Si vous appelez une fonction 500 fois au lieu de 50 000 fois en raison d'un meilleur algorithme, cela a un impact plus important que l'enregistrement de trois cycles d'horloge sur une micro-optimisation. Si vous ne bloquez pas plusieurs centaines de cycles sur un accès mémoire aléatoire tout le temps, cela a un impact plus important que de faire quelques calculs supplémentaires bon marché, etc.

L'optimisation est une question difficile (vous pouvez écrire des livres entiers à ce sujet et sans fin), et passer du temps à optimiser aveuglément un endroit particulier (sans même savoir si c'est le goulot d'étranglement!) Est généralement du temps perdu. Sans profilage, l'optimisation est très difficile à réaliser.

Mais en règle générale, lorsque vous volez à l'aveugle et que vous avez juste besoin/voulez faire quelque chose , ou comme stratégie générale par défaut, je suggère pour optimiser la "mémoire".
L'optimisation de la "mémoire" (en particulier la localisation spatiale et les modèles d'accès) donne généralement un avantage car contrairement à une fois où tout était "un peu le même", l'accès à RAM est aujourd'hui parmi les plus chers. des choses (à moins de lire sur le disque!) que vous pouvez en principe faire. Tandis que l'ALU, d'autre part, est bon marché et s'accélère chaque semaine. La bande passante mémoire et la latence ne s'améliorent pas aussi rapidement. Une bonne localité et de bons modèles d'accès peuvent facilement faire une différence 5x (20x dans des exemples extrêmes, récupérés) dans l'exécution par rapport à de mauvais modèles d'accès dans des applications gourmandes en données. Soyez gentil avec vos caches et vous serez une personne heureuse.

Pour mettre le paragraphe précédent en perspective, considérez ce que les différentes choses que vous pouvez faire vous coûtent. L'exécution de quelque chose comme a+b Prend (s'il n'est pas optimisé) un ou deux cycles, mais le processeur peut généralement démarrer plusieurs instructions par cycle, et peut canaliser des instructions non dépendantes, de manière plus réaliste, cela ne vous coûte qu'environ la moitié d'un cycle ou moins. Idéalement, si le compilateur est bon pour la planification, et selon la situation, cela pourrait coûter zéro.
La récupération des données ("mémoire") vous coûte soit 4-5 cycles si vous êtes chanceux et c'est en L1, et environ 15 cycles si vous n'êtes pas aussi chanceux (hit L2). Si les données ne sont pas du tout dans le cache, cela prend plusieurs centaines de cycles. Si votre modèle d'accès aléatoire dépasse les capacités du TLB (facile à faire avec seulement ~ 50 entrées), ajoutez quelques centaines de cycles supplémentaires. Si votre modèle d'accès aléatoire entraîne en fait un défaut de page, cela vous coûte quelques dix mille cycles dans le meilleur des cas, et plusieurs millions dans le pire.
Maintenant, pensez-y, quelle est la chose que vous voulez éviter de toute urgence?

0
Damon