web-dev-qa-db-fra.com

Pourquoi cette boucle de retard commence-t-elle à s'exécuter plus rapidement après plusieurs itérations sans sommeil?

Considérer:

#include <time.h>
#include <unistd.h>
#include <iostream>
using namespace std;

const int times = 1000;
const int N = 100000;

void run() {
  for (int j = 0; j < N; j++) {
  }
}

int main() {
  clock_t main_start = clock();
  for (int i = 0; i < times; i++) {
    clock_t start = clock();
    run();
    cout << "cost: " << (clock() - start) / 1000.0 << " ms." << endl;
    //usleep(1000);
  }
  cout << "total cost: " << (clock() - main_start) / 1000.0 << " ms." << endl;
}

Voici l'exemple de code. Dans les 26 premières itérations de la boucle de synchronisation, la fonction run coûte environ 0,4 ms, mais le coût se réduit ensuite à 0,2 ms.

Lorsque le usleep n'est pas commenté, la boucle de retard prend 0,4 ms pour toutes les exécutions, sans jamais accélérer. Pourquoi?

Le code est compilé avec g++ -O0 (pas d'optimisation), donc la boucle de retard n'est pas optimisée. Il est exécuté sur Intel (R) Core (TM) i3-322 CPU @ 3,30 GHz, avec 3.13.0-32-generic buntu 14.04.1 LTS (Trusty Tahr) .

71
phyxnj

Après 26 itérations, Linux augmente le CPU jusqu'à la vitesse d'horloge maximale puisque votre processus utilise sa pleine tranche de temps plusieurs fois de suite.

Si vous vérifiez avec des compteurs de performances au lieu de l'heure de l'horloge murale, vous constaterez que les cycles d'horloge de base par boucle de retard restent constants, confirmant qu'il s'agit simplement d'un effet de [~ # ~] dvfs [~ # ~ ] (que tous les processeurs modernes utilisent pour fonctionner à une fréquence et une tension plus écoénergétiques la plupart du temps).

Si vous avez testé sur un Skylake avec prise en charge du noyau pour le nouveau mode de gestion de l'alimentation (où le matériel prend le contrôle total de la vitesse d'horloge) , montée en puissance se passerait beaucoup plus rapidement.

Si vous le laissez fonctionner pendant un certain temps sur un CPU Intel avec Turbo , vous verrez probablement le temps par itération augmenter à nouveau légèrement une fois que les limites thermiques nécessitent la vitesse d'horloge pour réduire au maximum fréquence soutenue.


L'introduction d'un usleep empêche le gouverneur de fréquence CPU de Linux d'accélérer la vitesse d'horloge, car le processus ne génère pas 100% de charge, même à la fréquence minimale. (C'est-à-dire que l'heuristique du noyau décide que le CPU fonctionne assez rapidement pour la charge de travail qui y est exécutée.)



commentaires sur d'autres théories :

re: La théorie de David selon laquelle un changement de contexte potentiel de usleep pourrait polluer les caches : Ce n'est pas une mauvaise idée en général, mais cela n'aide pas à expliquer ce code.

La pollution du cache/TLB n'est pas du tout importante pour cette expérience . Il n'y a pratiquement rien à l'intérieur de la fenêtre de synchronisation qui touche la mémoire autre que la fin de la pile. La plupart du temps est passé dans une petite boucle (1 ligne de cache d'instructions) qui ne touche qu'un int de mémoire de pile. Toute pollution potentielle du cache pendant usleep est une infime fraction du temps pour ce code (le vrai code sera différent)!

Plus en détail pour x86:

L'appel à clock() lui-même peut manquer de cache, mais un manque de cache de récupération de code retarde la mesure de l'heure de début, plutôt que de faire partie de ce qui est mesuré. Le deuxième appel à clock() ne sera presque jamais retardé, car il devrait toujours être chaud dans le cache.

La fonction run peut être dans une ligne de cache différente de main (puisque gcc marque main comme "froide", donc elle est optimisée moins et placée avec d'autres fonctions/données froides ). On peut s'attendre à un ou deux échec du cache d'instructions . Cependant, ils sont probablement toujours dans la même page 4k, donc main aura déclenché le manque potentiel de TLB avant d'entrer dans la région chronométrée du programme.

gcc -O0 compilera le code de l'OP en quelque chose comme ça (Godbolt Compiler Explorer) : garder le compteur de boucles en mémoire sur la pile.

La boucle vide garde le compteur de boucle dans la mémoire de la pile, donc sur un Intel x86 CPU la boucle s'exécute à une itération par ~ 6 cycles sur le processeur IvyBridge de l'OP, grâce à la latence de transfert de magasin cela fait partie de add avec une destination mémoire (lecture-modification-écriture). 100k iterations * 6 cycles/iteration est de 600k cycles, ce qui domine la contribution d'au plus deux manquements de cache (~ 200 cycles chacun pour les échecs de récupération de code qui empêchent l'émission d'autres instructions jusqu'à ce qu'elles soient résolues).

L'exécution dans le désordre et le transfert de magasin devraient principalement cacher le cache potentiel manquant lors de l'accès à la pile (dans le cadre de l'instruction call).

Même si le compteur de boucles était conservé dans un registre, 100 000 cycles, c'est beaucoup.

121
Peter Cordes

Un appel à usleep peut ou non entraîner un changement de contexte. Si c'est le cas, cela prendra plus de temps que si ce n'est pas le cas.

3
David Schwartz