jeudi 3 juillet 2014

Linux : CPU, core, HyperThread, mesures de temps d'exécution

Introduction inutile

Dans l'article "Numérotation des processeurs", l'auteur (c'était moi) avait dit à propos des hyper-threads d'un même cœur :
Comme elles se partagent les unités de calcul, la puissance de calcul de ces deux hyper-threads, n'est (très généralement) pas le double de celle d'un processeur sans hyper-thread. (On reviendra sur cet aspect dans un article ultérieur).
Revenons-y donc, et profitons-en pour digresser.

La machine

Surdimensionnée pour les besoins de l'exercice certes (qui peut le plus peut le moins! Je ne savais pas que je citais Aristote!).  Voici le résultat de la commande hwloc-info (voir le projet hwloc) exécutée sur la machine utilisée.


Cette machine est équipée de 2 puces ayant chacune 6 cœurs hyper-threadés. Dans la suite on va utiliser le cœur N°1 et donc les CPU 2 et 14. Les processeurs équipant cette machine sont du type suivant: Intel(R) Core(TM) i5-3320M CPU @ 2.60GHz (obtenu via cat /proc/cpuinfo). Le système est un RedHat 6.3.

Le programme

Un programme relativement simple:
  • Une boucle de calcul saturant le processeur exécutée dans une puis 2 threads (logicielles)
  • On mesure le temps CPU d'une thread puis des deux via clock_gettime avec l'horloge CLOCK_THREAD_CPUTIME_ID
  • On mesure aussi le temps "réel" (mural) dans les 2 cas, via clock_gettime avec l'horloge CLOCK_REALTIME
  • Et pour s'assurer d'un déroulement raisonnable, 
    • On "épingle" les threads sur les processeurs de notre choix,
    • On donne à ces threads une priorité importante vis à vis de l'ordonnanceur.
    • Dans le cas des 2 threads, leur début d'exécution est synchronisé par une barrière (pthread_barrier_wait). 
  • Le source du programme est accessible ici.
  • En voici quelques extraits (franchement, on peut se dispenser de la lecture de ces extraits)
        /* Extrait du main */
    ctx_init();

    RUN(1);

    cpu_time = elapsed_time = 0.0;
    ADD_TIME(cpu_time, cpu, 0);
    ADD_TIME(elapsed_time, wall, 0);
    printf("ran 1 x %ld loops(%d), cpu time %.2f(ms) elapsed time %.2f(ms)\n", 

           nb_loops, LOOP_FACTOR, cpu_time, elapsed_time);

    pthread_barrier_init(&barrier, NULL, 2); /* best effort to run the 2 threads in // */
    th_ctx[0].wait_barrier = th_ctx[1].wait_barrier = 1;

    RUN(2);

    cpu_time = elapsed_time = 0.0;
    ADD_TIME(cpu_time, cpu, 0); ADD_TIME(cpu_time, cpu, 1);
    ADD_TIME(elapsed_time, wall, 0); ADD_TIME(elapsed_time, wall, 1);
    printf("ran 2 x %ld loops(%d), cpu time %.2f(ms) elapsed time %.2f(ms)\n", 

           nb_loops, LOOP_FACTOR, cpu_time, elapsed_time/2);

        /* Extrait du code des threads*/
   if (ctx->wait_barrier == 1) {
        res = pthread_barrier_wait(&barrier);
        if ((res != 0) && (res !=  PTHREAD_BARRIER_SERIAL_THREAD)) {
            perror("Problem in pthread_barrier_wait!");
            exit(1);
        }
    }
    if (ctx->cpu_id != -1) {
        res = pthread_setaffinity_np(ctx->th, sizeof(ctx->cpu_mask), &ctx->cpu_mask);
        EXIT_ON_ERR_NB(res, "Cannot bind to cpu");
    }

    clock_gettime(CLOCK_REALTIME, &ctx->start_time_wall);
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ctx->start_time_cpu);
    do_loop(ctx, nb_loops); /* do the real useless work */
    clock_gettime(CLOCK_THREAD_CPUTIME_ID, &ctx->end_time_cpu);
    clock_gettime(CLOCK_REALTIME, &ctx->end_time_wall);

        /* Calculs pour charger le CPU*/
void do_loop(thctx_t *ctx, long nb)
{
    long i, r, d, ci, si, vi, ai;
    double c,  s, v, a;
    /* a mix of long integer and double operations to generate CPU load */
    for (i = 0, r = 1; i < (nb_loops * LOOP_FACTOR); i++) {
        r = r + i;
        d = 2 * r;
        ci = d * 314 / 100; /* circle circumference long */
        si = r * r * 314 / 100; /* circle area long */
        ai = r * r * 314 * 4 / 100; /* sphere area long */
        vi = r * r * r * 314 *4 / 300; /* sphere volume long */
        c = d * 3.14; /* circle circumference double */
        s = r * r * 3.14; /* circle area double */
        a = r * r * 3.14 * 4; /* sphere area double */
        v = r * r * r * 3.14 * 4 / 3; /*sphere volume double */
    }
}

Exécution!

Lançons donc une exécution de notre programme. Il n'est pas nécessaire d'avoir les droits de super-utilisateur, mais dans la suite de nos expérimentations ces droits seront nécessaires. Le programme accepte un argument " -l nombre_de_boucles" et jusqu'à deux arguments " -p N°_de_cpu"

fa# ./cputime_core_vs_ht -l 4000
ran 1 x 4000 loops(100000), cpu time 10698.07(ms) elapsed time 10717.12(ms)
ran 2 x 4000 loops(100000), cpu time 21419.40(ms) elapsed time 10728.87(ms)

fa#

On a effectué 4000 x 100 000 boucles. Le placement des threads sur les processeurs était ici laissé au libre choix de l'ordonnancement du système.
  • En mono thread, on a utilisé  environ de 10,7 secondes de temps CPU et guère plus en temps mural.
  • Avec 2 threads en parallèle, Le temps CPU cumulé des deux threads est sensiblement le double. Le temps mural est calculé pour chaque thread, on présente ici la moyenne des 2 threads.
NOTE: On ne prétend pas ici faire des "benchmarks" précis, mais simplement illustrer et analyser quelques comportements. Les commandes ne sont donc exécutées qu'une seule fois et non pas des centaines (ou plus) de fois pour sortir des résultats statistiquement fiables. Mea maxima culpa.

HyperThread ou cœur!

On va utiliser les CPU N° 2 et N° 14. Si on se réfère à la topologie de la machine, il s'agit de deux hyper-threads du même cœur.
 
fa# ./cputime_core_vs_ht -l 4000 -p 2 -p 14
ran 1 x 4000 loops(100000), cpu time 10696.63(ms) elapsed time 10715.66(ms)
ran 2 x 4000 loops(100000), cpu time 32589.37(ms) elapsed time 16323.72(ms)

fa#

  • Le temps "mono-thread" est sensiblement identique à ce qui a été observé précédemment.
  • Le temps cumulé des "2 threads" est lui très différent! Chaque thread utilise environ 16,2 secondes de temps CPU, là où 10,7 secondes suffisent à une seule thread pour faire le même travail!  Le temps de calcul est augmenté d'environ 51%.
Cette augmentation s'explique par le fait  que les deux hyper-threads partagent des ressources processeurs (et cache) en commun. Notre programme est suffisamment petit pour qu'à priori le partage du cache ne soit pas le phénomène majeur dans la dégradation de performance, mais que ce soit le partage des ressources processeurs.

Vérifions! 

Forçons l'exécution de nos deux threads logiciels sur deux cœurs différents :

fa# ./cputime_core_vs_ht -l 4000 -p 2 -p 4
ran 1 x 4000 loops(100000), cpu time 10696.85(ms) elapsed time 10715.88(ms)
ran 2 x 4000 loops(100000), cpu time 21461.25(ms) elapsed time 10749.74(ms)

fa#

On retrouve des temps d'exécution similaires à ce qu'on obtient quand on laisse l'ordonnanceur choisir. Deux threads prennent ici le double du temps CPU d'une seule thread.

Et sur un seul processeur?

On force ici les deux threads à s'exécuter sur le même processeur (la même hyper-thread d'un même cœur).
 
fa# ./cputime_core_vs_ht -l 4000 -p 2 -p 2
ran 1 x 4000 loops(100000), cpu time 10732.28(ms) elapsed time 10751.44(ms)
ran 2 x 4000 loops(100000), cpu time 21447.26(ms) elapsed time 21474.75(ms)

fa#

AH!
  • Le temps CPU cumulé n'est plus de 30 secondes, mais de 21,4 secondes (le double du temps CPU utilisé par une thread unique). Logique, on n'a pas d'exécution réellement concurrente au niveau du matériel.
  • Le temps mural qui était de 16, 3 secondes lorsqu'on utilisait les 2 hyper-thread est maintenant de 21,4 secondes. 
  • On va donc moins vite, mais avec un temps CPU plus faible. Bonjour le dilemme! 

Perturbons!

On ajoute maintenant des perturbations sur le cœur utilisé. Dans les essais précédents, la machine était quasiment inactive. On peut utiliser le même programme ou un autre pour générer de la charge. On va lancer en parallèle un programme qui crée 6 threads logicielles épinglées sur les CPU N°2 et N° 14 et consommant potentiellement chacune le CPU à temps complet. (On pourrait lancer 3 fois 
./cputime_core_vs_ht -l 4000000 -p 2 -p 14).

fa# ./cputime_core_vs_ht -l 4000 -p 2 -p 14
ran 1 x 4000 loops(100000), cpu time 16681.62(ms) elapsed time 66824.23(ms)
ran 2 x 4000 loops(100000), cpu time 33392.32(ms) elapsed time 66900.33(ms)

fa#

  • La thread seule voit son temps CPU passé de 10.7 secondes à 16,2. C'est en fait comparable au temps CPU moyen observé lorsque l'on exécute les 2 threads logicielles sur les 2 hyper-thread d'un même cœur. Logique, puisqu'on a créé une exécution sur la deuxième hyper-thread.
  • Le temps mural de la thread unique est passé de moins de 17 secondes à 66 secondes! Là encore: logique. Le CPU N°2 est partagé entre 4 threads logicielles qui ne font que consommer du CPU, qu'elles obtiennent donc à tour de rôle (on n'a pas joué sur les priorités des processus/threads). Il leur faut environ donc 4 x fois le temps CPU. 
  • Le temps CPU des 2 threads est passé de 32,5 secondes à 33,3 secondes. La charge CPU additionnelle ne les a pas beaucoup perturbé. Normal, dans la première exécution, elles ser perturbent déjà mutuellement.
  • Le temps mural des 2 threads est lui augmenté pour les mêmes raisons que celui de la thread unique est augmenté.

Protégeons nous des perturbations!

Si l'on veut diminuer le temps mural, on peut envisager d'exécuter notre programme avec une priorité plus élevée que celle attribuée à la charge perturbatrice. Utilisons nice. (Là il faut avoir des droits de super-utilisateur).

fa# nice -n -20  ./cputime_core_vs_ht -l 4000 -p 2 -p 14
ran 1 x 4000 loops(100000), cpu time 16685.87(ms) elapsed time 17289.60(ms)
ran 2 x 4000 loops(100000), cpu time 32620.17(ms) elapsed time 16901.67(ms)

fa#
  • Le temps CPU n'est pas modifié : la charge perturbatrice est toujours à l’œuvre dans le cas de la thread unique.
  • Le temps mural est lui dramatiquement réduit: notre programme est prioritaire et donc les autres threads ne peuvent pas s'exécuter avant que notre programme prioritaire ait terminé.
Oui. mais si j'ai un programme "mono-thread" et que je veux le protéger des perturbations induites par des exécutions concurrentes sur le  même cœur?

Pas d'hyper-threading chez moi!

Ben. Il y a un moyen assez radical : on va débrancher la deuxième hyper-thread du cœur!
 
fa# echo 0 > /sys/devices/system/cpu/cpu14/online
fa# nice -n -20  ./cputime_core_vs_ht -l 4000 -p 2 -p 14
ran 1 x 4000 loops(100000), cpu time 10734.72(ms) elapsed time 11121.85(ms)
Cannot bind to cpu: Invalid argument

fa# echo 1 > /sys/devices/system/cpu/cpu14/online
fa#
  • Les threads logicielles perturbatrices qui utilisaient le CPU N°14 ne peuvent plus le faire, on a mis le CPU "hors ligne".  (Le système a poursuivi leur exécution ailleurs).
  • Les threads logicielles perturbatrices qui utilisent le CPU N°2 sont moins prioritaire que notre processus et donc ne le perturbe pas.
  • On retrouve dans le cas de la thread unique les temps observés au début de nos essais.
  • La deuxième thread ne peut pas s'épingler sur le processeur "hors ligne", notre programme s'arrête donc.
  • N'oubliez pas de remettre le CPU en ligne! Enfin, c'est vous qui voyez.

 Conclusion(s)

 Clairement, thread ou cœur: "Arthur réfléchis non de delà! Ça a une certaine importance."

Ne pas généraliser. Ici on n'a regardé que des cœurs hyper-threadés Intel sous Linux.

Des expériences sur un  UltraSparc T1 sous SunOS seraient amusantes à rapporter (suivant que la boucle est composées d'opérations sur entiers ou sur nombre flottants, on obtient des choses très différentes, ce qui s'explique par l'architecture du processeur).

On pourrait s'amuser à se poser des questions sur les outils de mesure d'activité CPU... Si sur mes 24 processeurs j'ai 2 processus (mono-thread) qui s'exécutent chacun sur un cœur différent et qui consomment du CPU, en veux-tu en voilà... La commande top va me dire que la capacité CPU de ma machine est occupée à 50 %.... Oui, mais je sais maintenant que la moitié restante ne peut pas fournir autant de travail que la première moitié... Donc 50 % est dans ce cas une sous-estitmation de la charge (ou tout au moins la disponibilité de 50 % est une interprétation erronée de l'affichage fournit par top).

On peut aussi regarder le problème de manière inverse (article à venir?) : au lieu de regarder quelle consommation CPU est induite par une charge, déterminer quel est le travail (par exemple le nombre de boucles) que l'on peut faire par intervalle de temps  suivant que l'on utilise une hyper-thread ou deux sur un même cœur.


Pfiouh ! Je ferais bien une conclusion, genre rédaction en école primaire (enfin ça faisait rage quand j'étais à l'école primaire) : "On est rentré chez nous, on s'était bien amusé".

Pour s'amuser plus tard!

A propos de mon programme perturbateur... Si on regarde une des ses threads logicielles épinglée sur le CPU N° 14:
  • Elle ne peut initialement s'exécuter que sur le CPU N°14. On peut vérifier en jetant un coup d'oeil dans /proc/[pid]/task/[tid]/status : Cpus_allowed_list :    14
  • Quand on met le processeur N° 14 hors ligne on obtient alors :  Cpus_allowed_list:    0-13,15-23
  • Si on remet le processeur N° 14 en ligne : on reste avec la valeur : Cpus_allowed_list:    0-13,15-23
Ça m'amuse vraiment beaucoup. Mais là je rentre vraiment chez moi.