web-dev-qa-db-fra.com

L'accès à une variable de fonction statique est-il plus lent que l'accès à une variable globale?

Les variables locales statiques sont initialisées lors du premier appel de fonction:

Les variables déclarées à la portée du bloc avec le spécificateur static ont une durée de stockage statique mais sont initialisées la première fois que le contrôle passe par leur déclaration (à moins que leur initialisation ne soit une initialisation nulle ou constante, qui peut être effectuée avant la première entrée du bloc). Lors de tous les autres appels, la déclaration est ignorée.

De plus, dans C++ 11, il y a encore plus de vérifications:

Si plusieurs threads tentent d'initialiser la même variable locale statique simultanément, l'initialisation se produit exactement une fois (un comportement similaire peut être obtenu pour des fonctions arbitraires avec std :: call_once) . Remarque: les implémentations habituelles de cette fonctionnalité utilisent des variantes du schéma de verrouillage à double vérification, ce qui réduit les délais d'exécution liés à la statique locale déjà initialisée en une seule comparaison booléenne non atomique. (depuis C++ 11)

Dans le même temps, les variables globales semblent être initialisées au démarrage du programme (même si, techniquement, seul allocation/deallocation est mentionné dans cppreference):

durée de stockage statique. La mémoire de l'objet est allouée au début du programme et désallouée à la fin du programme. Une seule instance de l'objet existe. Tous les objets déclarés à la portée de l'espace de noms (y compris l'espace de noms global) ont cette durée de stockage, plus ceux déclarés avec static ou extern.

Donc, étant donné l'exemple suivant:

struct A {
    // complex type...
};
const A& f()
{
    static A local{};
    return local;
}

A global{};
const A& g()
{
    return global;
}

ai-je raison de supposer que f() doit vérifier si sa variable a été initialisée à chaque appel; ainsi f() sera plus lent que g()?

25
Dev Null

Vous êtes conceptuellement correct, bien sûr, mais les architectures contemporaines peuvent y faire face.

Un compilateur moderne et une architecture organiseraient le pipeline de sorte que la branche déjà initialisée soit assumée. Les frais généraux de l'initialisation impliqueraient donc un vidage supplémentaire de pipeline, c'est tout.

En cas de doute, vérifiez l’Assemblée.

15
Bathsheba

Oui, il est presque certainement un peu plus lent. La plupart du temps, cela n'aura cependant aucune importance et le coût sera compensé par l'avantage "logique et style".

Techniquement, une variable statique locale est identique à une variable globale. Son nom est simplement (non}) mondialement connu (ce qui est une bonne chose) et son initialisation est garantie non seulement à une heure précise, mais aussi une fois et threadsafe.

Cela signifie qu'une variable statique locale à fonction doit savoir si une initialisation a eu lieu et nécessite donc au moins un accès mémoire supplémentaire et un saut conditionnel dont la globale (en principe) n'a pas besoin. Une implémentation peut est similaire pour les globals, mais elle ne nécessite pas (et généralement pas).

Les chances sont bonnes que le saut soit prédit correctement dans tous les cas sauf deux. Il est très probable que les deux premiers appels soient mal prédits (généralement, les sauts sont supposés être pris plutôt que de ne pas être pris, hypothèse erronée lors du premier appel, et les sauts suivants sont supposés suivre le même chemin que le dernier, encore faux). Après cela, vous devriez être prêt à partir, avec une prévision proche de 100%.
Mais même un saut correctement prédit n’est pas gratuit (la CPU ne peut toujours commencer qu’un nombre donné d’instructions à chaque cycle, même en supposant qu’elles ne prennent que zéro), mais ce n’est pas beaucoup. Si la latence de la mémoire, qui peut représenter quelques centaines de cycles dans le pire des cas, peut être masquée avec succès, le coût presque _ disparaît dans le traitement en pipeline. De plus, chaque accès récupère une cacheline supplémentaire dont on ne disposerait pas autrement (l'indicateur has-been-initialized n'a probablement pas été stocké dans la même ligne de cache que les données). Ainsi, vos performances en L1 sont légèrement inférieures (L2 devrait être suffisamment grande pour que vous puissiez dire "ouais, alors quoi").

Il doit également effectuer quelque chose que une fois et threadsafe que le global (en principe) n’a pas à faire, du moins pas d’une manière que vous voyez. Une implémentation peut faire quelque chose de différent, mais la plupart ne font qu'initialiser les globales avant que main ne soit entrée, et rarement avec une memset ou implicitement car la variable est stockée dans un segment mis à zéro de toute façon.
Votre variable statique doit être initialisée lorsque le code d’initialisation est exécuté, et cela doit se faire de manière thread-safe. Cela peut coûter assez cher, tout dépend de votre mise en œuvre. J'ai décidé de ne plus utiliser la fonctionnalité de sécurité des threads et de toujours compiler avec fno-threadsafe-statics (même si ce n'est pas conforme à la norme) après avoir découvert que GCC (qui est par ailleurs un compilateur complet OK) verrouillerait un mutex pour chaque initialisation statique.

6
Damon

De https://en.cppreference.com/w/cpp/language/initialization

Initialisation dynamique différée
Il est défini par l'implémentation si l'initialisation dynamique survient avant la première instruction de la fonction principale (pour la statique) ou la fonction initiale du thread (pour les locus de thread), ou différée après.

Si l'initialisation d'une variable non inline (depuis C++ 17) est différée après la première instruction de la fonction main/thread, elle survient avant la première utilisation par odr d'une variable avec une durée de stockage statique/thread définie dans même unité de traduction que la variable à initialiser. 

Donc, une vérification similaire may doit également être effectuée pour les variables globales.

alors f() n'est pas nécessaire "plus lent" que g().

2
Jarod42

g() n'est pas thread-safe et est sujet à toutes sortes de problèmes de commande. La sécurité va avoir un prix. Il y a plusieurs façons de le payer:

f(), le Singleton de Meyer, paie le prix de chaque accès. Si vous y accédez fréquemment ou lors d'une section de votre code sensible aux performances, il est judicieux d'éviter f(). Votre processeur a vraisemblablement un nombre fini de circuits qu’il peut consacrer à la prédiction de branche, et vous êtes forcé de lire une variable atomique avant la branche de toute façon. C'est un prix élevé de payer continuellement pour s'assurer que l'initialisation n'a eu lieu qu'une fois.

h(), décrit ci-dessous, fonctionne très bien comme g() avec un indirection supplémentaire, mais suppose que h_init() est appelé exactement une fois au début de l'exécution. De préférence, vous définiriez un sous-programme appelé en tant que ligne de main(); qui appelle chaque fonction comme h_init(), avec un ordre absolu. Espérons que ces objets n'ont pas besoin d'être détruits.

Si vous utilisez GCC, vous pouvez également annoter h_init() avec __attribute__((constructor)). Je préfère cependant le caractère explicite du sous-programme statique init.

A * h_global = nullptr;
void h_init() { h_global = new A { }; }
A const& h() { return *h_global; }

h2() est comme h(), moins l'indirection supplémentaire:

alignas(alignof(A)) char h2_global [sizeof(A)] = { };
void h2_init() { new (std::begin(h2_global)) A { }; }
A const& h2() { return * reinterpret_cast <A const *> (std::cbegin(h2_global)); }
0
KevinZ