samedi 20 décembre 2014

Impact inattendu(?) sur les performances d'un programme. Vaz'y PAPI.

  • "Faire une mesure est relativement aisé. C'est quand on a obtenu la mesure que les emm... commencent." (Moi).
  • "Et j'aimerais bien savoir quand ils finissent!" (Re-moi) 
  • "Sans compter tous les emm... annexes."  (Toujours moi)
  • "Il y a beaucoup de mérite
    Sans compter les emmerdements.
    " (Extrait de "Qu’y a-t-il…?" de Boris Vian)

Indiquons

Soit un simple morceau de code qui cherche à exprimer la puissance de calcul d'un CPU, genre nombre d'instructions / de boucles  ou ce que vous voulez par secondes. Par exemple quelque chose qui ressemblerait aux calculs faits dans la procédure do_loop  utilisée dans Linux : CPU, core, HyperThread, mesures de temps d'exécution. De manière concrète, dans cet article, on a exécuté 4000 boucles (de 100000) en 10696 millisecondes, soit un indicateur de 4000/10,696 = 373,97 FauxPlat/sec(*) pour le processeur en question.

(*) Le FauxPlat/sec est une unité de puissance de CPU créée pour les besoins de ce billet. Le FauxPlat/sec est invariable : 1 FauxPlat/sec 2 FauxPlat/sec. Il n'y a pas d’abréviation officielle pour le FauxPlat/sec. FP/sec est prohibé en tant qu’abréviation de FauxPlat/sec. 

Blindons

Une fois écrit ce code, reste à s'assurer qu'il est exécuté dans des conditions relativement stables et protégées  des perturbations provoquées par l'exécution d'autres programmes. Le but : obtenir des valeurs  identiques ou raisonnablement proches pour des machines basées sur le même CPU. Ce qui devrait permettre (conditionnel dans un monde idéal) de comparer des machines...

Dans la mesure où la durée du calcul est relativement courte (~11 sec dans l'article cité), on peut se permettre d'exécuter ce calcul en utilisant une classe d'ordonnancement temps-réel (SCHED_FIFO) avec une priorité maximum. Pour limiter encore davantage les perturbations, l'utilisation de l'affinité processeur (sched_setaffinity) évitera de compter des coûts de migration CPU. Et comme les processeurs modernes sont de grands taquins et qu'ils ont la capacité de faire varier leur fréquence, on va aussi fixer la fréquence du processeur au maximum. (Euh, là, les bidouilles sont à découvrir vous-mêmes, à moins qu'elles ne soient livrées dans un billet ultérieur).

Ratons (pas laveurs)

Hmmm. Comme dit précédemment, la loi de Murphy indique que forcément non... Dans les faits, si vous pouvez (faire) réaliser ce type de calcul sur des centaines de machines, les variations relevées sur des machines de CPU identiques vont être surprenantes... de 1 à 3. Gloups. Pas systématique, la grosse majorité des résultats va varier dans une fourchette de disons 0 à 30%. Mais la masse de mesures joue contre vous et vous avez donc un nombre de mesures pour lesquellesvous ne savez pas justifier les variations.  Il peut arriver, expérience vécue*, qu'il faille changer la carte mère pour obtenir des résultats homogènes avec des CPUs identiques..

* euh? y'a des expériences pas vécues?

Piqué au vif soyons

Vous reprenez donc votre programme, et sur vos (quelques, car vous, vous n'avez pas accès à des centaines) machines, tentez de perturber ce calcul.
  • Essai n°1: Ajout d'une charge CPU importante sur le même processeur. Résultat : variation de 10% du nombre de FauxPlat/sec.
Regardez bien, ma sœur ;
Est-ce assez ? dites-moi ; n'y suis-je point encore? - Nenni. 
  • Essai n°2 : Ajout d'une charge CPU importante  faisant de nombreux accès mémoire pour perturber le cache L1. Résultat : variation de 20 % du nombre de FauxPlat/sec.
- M'y voici donc ?
- Point du tout.
  • Essai n° 3 : Ajout d'une charge CPU importante s'exécutant au même niveau de priorité temps-réel que le calcul de l'indicateur. Le calcul étant fait sur la mesure du temps CPU et non du temps mural... cela ne change rien, par ailleurs, il est relativement facile de prouver que cette situation ne semble pas être rencontrée sur vos machines de production...
M'y voilà ?
- Vous n'en approchez point.
Merci Mr Jean de La Fontaine.
  • Essai n°4 : Certes vous avez fixé la fréquence du processeur au maximum. Mais qui vous assure que cette fréquence n'est pas modifiée par un autre programme pendant vos exercices?  D'accord. Quand on fait varier la fréquence du simple au double, le nombre de FauxPlat/sec varie aussi du simple au double... Mmm Il y a là encore peu de chance que ce soit ce qui se passe sur vos machines de production...

10 wagons euh, divaguons

Fatigué, harassé, vous en êtes là et même un peu las de vos vaines et ridicules tentatives. Quand vous découvrez au hasard d'une errance webique la publication suivante (dont la lecture vous est vivement recommandée) : 

Producing Wrong Data Without Doing Anything Obviously Wrong!
Todd Mytkowicz, Amer Diwan, Matthias Hauswirth, Peter F. Sweeney
ASLPOS09 

où il est exposé et démontré que des mesures de performances peuvent être sérieusement altérées par des éléments n'ayant rien à voir ni avec la choucroute ni avec le yaourt. Il est dit qu'un programme aussi simple que ceci :

static int i = 0, j = 0, k = 0;
int main() {
    int g = 0, inc = 1;
    for (; g < 65536; g++) {

        i += inc;
        j += inc;
        k += inc;
    }
    return 0;
}

peut voir ses temps d'exécution  sérieusement modifiés selon... la taille des variables d'environnement du programme. Le graphique suivant extrait de l'article montre une variation du nombre de cycles nécessaires pour la boucle en fonction de la taille des variables d'environnement passées au programme. On voit très nettement, que dans un certain nombre de cas le nombre de cycles nécessaire est de 30% supérieur au cas "normal". Et dans un cas extrême il est de 300% supérieur au cas normal...

L'article décrit aussi des effets de bord extrêmement hilarants (de la Baltique) de l'ordre dans lequel les .o sont passés fournis lors de l'édition de liens sur les performances.

Reproduisons

L'expérience (la vôtre, donc la mienne en l’occurrence. Vous me suivez? Expérience donc tout aussi vécue que la précédente, même si ici, on ne se réfère pas à une expérience précise mais à l'Expérience que l'on pourrait définir comme le cumul de toutes les expériences vécues.)  L'expérience, donc, vous a rendu méfiant. Et vous voilà donc parti sur la piste de la répétition d'une expérience. Eh, oh. C'est un article scientifique... Qu'est-ce qui pourrait.. .

Les mesures ont été faites à l'aide de la bibliothèque PAPI (Performance Application Programming Interface) pour calculer le nombre de cycles utilisés pour la boucle...
  • téléchargement,
  • ./configure
  • make 
  • make install
Allons-y.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "papi.h"

#define NUM_EVENTS 1
long long values[NUM_EVENTS];
unsigned int events[NUM_EVENTS]={PAPI_TOT_CYC};
 

static int i = 0, j = 0, k = 0;

int main() {
    int g = 0, inc = 1;

    int retval;

    if((retval = PAPI_start_counters((int*)events, NUM_EVENTS)) 
        < PAPI_OK) {
         printf("argh %s\n", PAPI_strerror(retval));
         exit(1);
    }

    for (; g < 65536; g++) {

        i += inc;
        j += inc;
        k += inc;
    }

  
    if((retval = PAPI_stop_counters(values, NUM_EVENTS))
        < PAPI_OK) {
        printf("argh %s\n", PAPI_strerror(retval));
        exit(1);
    }

    printf("%lld\n", values[0]);

    PAPI_shutdown();

    return 0;
}

Vous (re)prenez des précautions élémentaires :
  • sur votre machine avec 24 processeurs, qui ne font pas grand chose, vous choisissez un processeur reculé au fin fond d'un core de la deuxième socket. Et vous jouez de l'affinité (taskset) pour être sûr que vous ne laisserez pas ce programme s'exécuter ailleurs.
  • Vous utilisez  aussi chrt pour vous donner une priorité bien méritée.
  • Si vous êtes vraiment parano, vous y allez même d'un coup de numactl, pour être certain que la mémoire va être allouée pas trop loin de votre processeur. (Note pour plus tard : faudra regarder ça de plus près ces trucs de NUMA.)
Et vous vous lancez dans un script qui
  • élimine toutes les variables d'environnement,
  • lance  100 fois votre programme avec comme seule variable d'environnement
  • A=
  • puis avec A=a
  • puis avec A=aa
  • etc jusqu'à 8192 ajouts.
Et vous calculez la moyenne des 100 exécutions faites pour chaque valeur de la variable d'environnement. Jusqu'à...

Un café sirotons

Bon, il faut plus d'un café... Chaque essai étant réalisé 100 fois pour chacune des 8192 tailles d'environnement, le résultat est disponible environ 1H45 après le lancement du script.


Déception! 

Le comportement décrit dans l'article  ne semble pas se reproduire. Certes, quelques mesures gigotent autour des 500 000 cycles, mais la moyenne étant à 490 000 cycles... nous sommes loin des 30 % de variations constatés dans l'article. Et pas aberration en vue à 300 %.

Persévérons

Oui, enfin... Au chat, notre langue donnons! Un échange avec un des auteurs de l'article conduit sur la piste de ASLR! Oui "Adresse Space Layout Randomization" décrit par DjiBee. La tentative précédente a été effectuée sur un système dans lequel ce mécanisme était activé.

Désactivons ASLR.


fa# echo 0 >/proc/sys/kernel/randomize_va_space
fa#

Et ré-essayons. Un autre café, et 1H45 plus tard:


C'est encore très différent du comportement observé dans l'article initial. Mais malgré tout, vous observez ici des variations entre 14% et 30 % par rapport à la moyenne. 

Zoomons

Si vous zoomez sur le premier pic, vous obtenez :


Et sur le deuxième pic :


Les 2 pics se ressemblent : 16 points chacun. Le delta entre les 2 n'est pas exactement  de 4096...Mais de 4093... Un peu décevant... Mais, tolérant soyons.

L'observation semble être répétable. Si vous avez quelques paquets de 1H45 à perdre, investir, gaspiller, il semble bien que vous obteniez sensiblement les mêmes résultats.

Ailleurs voir allons

Les essais précédents ont été faits sur une machine à base de processeurs Xeon E5-2420 en utilisant un compilateur GCC 4.4.6.
Donc, vous trouvez une autre machine, merci YuRug, avec 40 cores, donc 80 CPU. Et 1H45 plus tard, vous obtenez une variation entre le point minimum et maximum d'environ 2%...
"Rien ne t'affole." (Téléphone).

Vous vous apprêtez à relancer le test en désactivant l'ASLR... Vous pouvez vous avez le droit.

Capitulons

Oui, mais non... le système vous injurie. En disant que echo 0 dans randomize_va_space, là ça va pas le faire. uh ?Un coup de Google Est Ton Ami, et rien. Abandonné en rase campagne. Bon. Faudrait plonger  dans les sources du noyau.

Mais. C'est Décembre sérieusement avancé, comme un camembert. Faut aller remplir la hotte de PAPI (tiens?) Noël. Sans compter que vous avez un tas de 68 copies qui vous attend...

Alors... 

Concluons

Ah, ben. Pas trop avancé, là. Faudrait vraiment refaire cette expérience sur d'autres processeurs. Les impacts entre environnements et performance semblent bien possible. Si dans les lecteurs de ce billet il y a des amateurs qui veulent partager leurs résultats, la porte est grande ouverte! 

Est-ce que ça explique les variations de FauxPlat/sec  recherchées initialement ? Murphy traînant toujours dans les parages, j'ai du mal à y croire... 
  
"Il y a beaucoup de mérite
Sans compter qu’il y a des emmerdements."
(Boris Vian)

 

dimanche 9 novembre 2014

Codage et paresse, un exemple d'économie d'effort contre-productive.

Note liminaire

Aïe ! Je me suis fait mordre par un bogue, qui pour une fois n'était pas de mon fait. Pas le genre bogue enragé, mais qui (in)visible comme le nez au milieu de la figure ne s'est pas fait remarquer immédiatement.

Je dévisage rarement avec insistance l'appendice nasal de mes interlocuteurs... 
Mais, d'ailleurs, peut-on dévisager un nez ? 

Mise en scène

Bref. Je résume la situation. Une séquence de code (la création d'un processus) tout ce qu'il y a de plus banal dont on cherche à mesurer la durée. Un appel à gettimeofday au début, un autre à la fin. Une petite différence entre temps relevé à la fin et temps relevé au début, et hop.

Oui mais le temps résultant est négatif (très négatif!).  Ah, vous m'étonnez fort ! Auriez-vous inventé un programme à remonter le temps ?

Jetons un coup d’œil !

#include  <stdlib.h>
#include  <stdio.h>

int main (int argc, char **argv)
{
        struct timeval t0, t1;
        pid_t pid;

        gettimeofday(&t0, NULL);
        pid = fork();
        if (pid == 0) {
                return 0;
        }
        gettimeofday(&t1, NULL);
        wait();
        printf("temps: %ld µsec\n",
        ((t1.tv_sec - t0.tv_sec) *1000000L) +
         (t1.tv_usec - t0.tv_usec));
        return 0;
}


D'accord, codage minimaliste. Pas de test des codes retours, mais bon, soyons joueur !

Que donne la compilation ?

fa $ gcc lazzy_wb.c -o lazzy_wb
fa $

Super, ça compile sans erreur et sans warning. D'accord, quitte à être paresseux autant l'être aussi sur la commande de compilation.

Voyons l'exécution...

fa $ ./lazzy_wb
temps: -970715068 µsec
fa $

Ah, effectivement, nous voilà rajeunis d'environ 970 secondes.

Est-ce qu'on peut dire "le temps passe vite", quand il passe en marche arrière? 

Investiguons

Je vous la joue courte, sinon, Jérôme, va me dire "tout ça pour ça". :-)

Vous inspectez avec toute votre concentration le code, vérifiez le calcul de la différence. Rien, pas de loup caché ici. Un coup de strace et de ltrace pour vérifier si les appels systèmes se passent correctement. Toujours rien en vue, pas de frémissement dans ma synapse.

Allez, l'arme fatale ! Roulements de tambour: gdb.

Point d'arrêt après le deuxième "gettimeofday", vérification des valeurs de t0 et de t1.
Ben elles sont correctes.
Panne sèche...

Pfiouh les 970 secondes gagnées il y a peu sont en train de s'évaporer dans le présent / futur à vitesse grand V!

Et puis d'un coup d'un seul ! Le nez, là, au milieu de la figure ! Eh, oh! Y'a pas de nez!

Un petit coup de RTFM, man wait....

Ah, ben tiens, wait prend un paramètre, intempestivement  passé aux oubliettes dans notre code.

Essayons pour voir!


fa $ sed -i -e '/wait(/s/wait(/&NULL/' lazzy_wb.c
fa $ gcc lazzy_wb.c -o lazzy_wb
fa $ ./lazzy_wb
35 µsec
fa $

Pardon ? Passer "NULL" comme paramètre à wait suffit à corriger notre problème?

Analyse

wait, s'il reçoit un paramètre non NULL, le considère comme un pointeur sur entier et copie dans l'entier ainsi pointé la valeur de retour du processus fils décédé. Dans notre cas, 0. Ce que confirme gdb après le wait le champ t1.tv_sec est bien écrasé à 0. D'où une différence de temps négative.

Un coup d’œil à l'assembleur

Sans le paramètre

    movl      $0, 4(%esp)
    leal        40(%esp), %eax
    movl      %eax, (%esp)
    call        gettimeofday
    movl      %eax, 24(%esp)

    call        wait

Avec le paramètre

    movl       $0, 4(%esp)
    leal         40(%esp), %eax
    movl       %eax, (%esp)
    call         gettimeofday
    movl       %eax, 24(%esp)
    movl       $0, (%esp)
    call         wait

Quand on invoque wait avec NULL, on empile effectivement une valeur nulle sur la pile comme paramètre de wait. Sans paramètre, on ne fait, rien... et wait qui croit qu'on a mis une valeur en pile, va utiliser ce qui se trouve en sommet de pile... et pas de chance, c'est le dernier paramètre poussé sur la pile par le compilateur... à savoir l'adresse de t1. Bingo!

Morale(s)

  •  Ne pas passer de paramètre n'est pas équivalent à passer un paramètre NULL. Ma fainéantise naturelle me susurre que c'est bien dommage. 
  • Une compilation avec -Wall nous aurait indiqué que les prototypes de fork, wait et gettimeofday n'étaient pas définis.
  • Après ajout des directives d'inclusion idoines, nous aurions eu le message suivant  error: too few arguments to function ‘wait’

Bilan

2 à 3 minutes épargnées à ne pas effectuer les inclusions appropriées.
10 minutes perdues à 2 pour identifier et corriger.

Plus une heure pour écrire ce billet, et je ne compte pas le temps que vous venez de perdre à le lire.
Tout ça pour ça!
Désolé!

mercredi 29 octobre 2014

Vacances au sous-shell


Bon. J'ai raté la fin des vacances et la rentrée... Mais je suis juste à temps pour la rentrée des vacances de la Toussaint, non ? 

Jour de la rentrée dans la cour de récréation...

  • Octet Touffu : Alors, ces vacances?
  • Octet Foutu  : M'en parle pas, je me suis perdu dans les sous-shell.

Égarement

Le temps des vacances. Flânerie, on écrit quelques lignes de shell-script... Non, je n'ai pas dit avoir codé du shell script pendant mes vacances... mais pendant le temps des vacances... des autres. Nuance.

Et hop, une ligne en appelle une autre, du coup voilà une fonction... qui doit faire une boucle et récupérer un résultat facile... Je simplifie et vous épargne les 1 500 lignes de script environnantes !
Oui, je sais, quand on atteint ce volume, on s'est trompé de langage. Python serait probablement plus adapté. Vous ne faites jamais d'erreur vous?

 Et une fonction shell, une.

function get_fs()
{
    df  | sed -e 1d | awk '{print $1}' | while  read FS
    do
         FS_ARRAY[$CNT]=$FS
         CNT=$((CNT + 1))
    done

Facile. On récupère le premier champ de champ de chaque ligne utile imprimée par df, et on le stocke dans un tableau. Du coup, après on espère pouvoir utiliser le tableau!

Essai

fa# get_fs
fa# echo ${FS_ARRAY[0]}


fa# Euh, y'a rien là au dessus?

Pardon?  Eh? Où est la valeur de mon tableau?
Oui, je vous vois, tordus, pliés, rigolards.

N'empêche, persévérons

On met donc des traces dans la fonction et on réessaye ! Pour plus de sûreté, on trace l'entrée du tableau après son initialisation et le compteur après son incrémentation.

fa# function get_fs()
{
    df  | sed -e 1d | awk '{print $1}' | while  read FS
    do
         FS_ARRAY[$CNT]=$FS
         echo ${FS_ARRAY[$CNT]}
         CNT=$((CNT + 1))
         echo $CNT
    done


fa# get_fs
/dev/sda1
1
udev
2
tmpfs
3

fa# echo ${FS_ARRAY[0]}

fa# Euh, y'a toujours rien là au dessus?

Souvenirs, souvenirs

Et là, se profile l'idée que peut-être  la fonction procéderait au moyen d'un sous-shell, qui initialiserait sa copie de FS_ARRAY... mais que cette copie serait somme toute perdue quand on quitte le sous-shell. Et donc, il faudrait trouver un autre moyen pour remplir ce tableau. Ah, oui le shell, les sous-shells.

Mais, vexé, on voudrait quand même savoir si c'est bien  un sous-shell la cause de notre perplexité, sans parler de la déception. 

Et donc on vérifie!

Imprimons donc le PID de nos shells et sous-shells avec la variable $$ .

fa# function get_fs()
{
    df  | sed -e 1d | awk '{print $1}' | while  read FS
    do
         FS_ARRAY[$CNT]=$FS
         echo shell $$ sets ${FS_ARRAY[$CNT]}
         CNT=$((CNT + 1))
         echo $CNT
    done


fa# get_fs
shell 2130 sets /dev/sda1
shell 2130 sets udev
shell 2130 sets tmpfs

fa# echo $$
2130
fa# Euh, Le shell et le sous-shell auraient le même pid? Ou il n'y a pas de sous-shell? Qu'est-ce que cette embrouille?

Je ne crois même pas ce que je vois !

Comme on subodore une entourloupe et qu'on connaît un peu son système, si on allait chercher nous même le pid du processus? Il faut quand même une bonne dose de paranoïa pour ne plus faire confiance au shell pour quelque chose d'aussi simple que echo $$ !
 
fa# function get_fs()
{
    df  | sed -e 1d | awk '{print $1}' | while  read FS
    do
         read -r MYPID GARBAGE
        FS_ARRAY[$CNT]=$FS
         echo shell $MYPID $$ sets ${FS_ARRAY[$CNT]}
         CNT=$((CNT + 1))
         echo $CNT
    done


fa# get_fs
shell 5707 2130 sets /dev/sda1
shell 5707 2130 sets udev
shell 5707 2130 sets tmpfs

fa# read -r MYPID GARBAGE
fa# echo $MYPID $$
2130 2130
fa# Ah! Ah! Aurais-je trouvé un bug grossièrement trivial? Pourquoi $$ donne-t-il 2130 dans les 2 cas, alors que /proc donne 2130 pour l'un et 5707 pour l'autre?
       
Donc, le pid du sous-shell diffère de $$ ? Des certitudes s'effondrent. Retour aux sources: RTFM.

RTFM ! Et le manuel nous dit:

$   Expands to the process ID of the shell. In a () subshell, it expands to the process ID of the current shell, not the subshell.

En clair, le manuel nous dit, apprend à lire. En fait, je ne sais pas si j'ai jamais relu cette partie depuis la page manuel d'un bon vieux shell Bourne d'un Unix V7.

 Vérification! Un coup d’œil dans un bon vieux manuel V7 trouvé là http://plan9.bell-labs.com/7thEdMan/v7vol1.pdf
$ The process number of this shell.

Ah flûte, le monde évolue ! On a modifié la sémantique de de $$ sans m'avertir ! Serait-il nécessaire de se tenir au courant  ?

Simplifions avec BASHPID et BASH_SUBSHELL

fa# function get_fs()
{
    df  | sed -e 1d | awk '{print $1}' | while  read FS
    do
        FS_ARRAY[$CNT]=$FS
         echo shell $BASHPID $BASH_SUBSHELL $$ sets ${FS_ARRAY[$CNT]}
         CNT=$((CNT + 1))
         echo $CNT
    done


fa# get_fs
shell 5715 1 2130 sets /dev/sda1
shell 5715 1 2130 sets udev
shell 5715 1 2130 sets tmpfs

fa# echo $BASHPID $BASH_SUBSHELL $$
2130 0 2130
fa# 

 Voilà! $BASHPID est la variable qui indique réellement le pid du processus shell courant. et $BASH_SUBSHELL est une indication d'un niveau de sous-shell. A utiliser la prochaine fois qu'un cas douteux se présentera.

Tatillons

Je vous connais, lecteurs tatillons ! D'accord, il sait maintenant déterminer si le code est exécuté dans un sous-shell ou non, mais il ne peut toujours pas récupérer le tableau initialisé par la fonction dans le shell principal. Voilà un moyen comme un autre.


fa# function get_fs()
{
    for FS in $(df  | sed -e 1d | awk '{print $1}')
    do
        FS_ARRAY[$CNT]=$FS
         echo shell $BASHPID $BASH_SUBSHELL $$ sets ${FS_ARRAY[$CNT]}
         CNT=$((CNT + 1))

    done


fa# get_fs
shell 6432 0 6432 sets /dev/sda1
shell 6432 0 6432 sets udev
shell 6432 0 6432 sets tmpfs

fa# echo$CNT ${FS_ARRAY[0]}
7 /dev/sda1


fa# Ah, enfin!

Bref

Le shell (et spécialement les sous-shells) contrairement au vélo, ça s'oublie.
Il faut toujours lire les manuels des nouvelles versions.

mercredi 6 août 2014

Est-ce bien de la programmation ?

Non, ce n'est pas de la programmation mais une pause pour célébrer l'été. L'auteur a eu la chance de se balader dans l'electronic town d'Akihabara à Tokyo. Bien entendu ce quartier est mythique pour les nipponophiles, geeks, technophiles et otaku en tous genres. Oui comme attendu le quartier regorge de magasins tous plus illuminés les uns que les autres et qui vendent par brassées entières ordinateurs, smartphones, caméra, appareils divers tous comportant un part importante d'électronique. Oui comme attendu on y trouve des salles de jeux à n'en plus finir et revendeurs de jeux vidéos de toutes marques et époques (le must étant la pseudo-rétro-console bourrée de simulateurs de jeux des années 80). Oui on croise une faune des plus bariolées. Oui, oui à tous les clichés. Mais le plus intéressant et impressionnant n'est pas là, c'est dans les minuscules ruelles et arrière-cours que l'on trouve le plus magnifique : les revendeurs de pièces. Le paradis des amateurs d'électronique! Chaque revendeur est spécialisé : celui qui vend tous les modèles possibles de boutons poussoirs lumineux, celui qui vend toutes les résistances possibles, les condensateurs, les batteries, les fils, les cables, les antennes, les potards, les cpu, les horloges, etc. Un éblouissement pour les yeux. Si vous avez auparavant visité le marché aux poissons de Tsukuji (dépêchez-vous il va disparaître) vous y verrez le pendant électronique. Tous ces objets étant triés/rangés très soigneusement dans des étals ouverts sur rue, un bazar électronique mais à la Japonaise! Il n'est pas facile de trouver des images; je n'ai pas osé prendre des photos de ses sympathiques marchands qui me l'auraient probablement autorisé; mais vous aurez ici un petit aperçu. Bah, c'est pas de la programmation mais sans tous ces trucs, en ferions-nous ?

DjiBee

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.

vendredi 20 juin 2014

printf() magic tricks and %n specifier

Si l'on sait que la fonction printf est puissante, sa puissance est rarement utilisée. Sans doute la syntaxe de spécification du format n'est-elle pas très agréable. C'est vrai, mais je ne connais pas de fonction de sortie pour laquelle la spécification d'un format soit agréable; c'est, par nature, compliqué.

On trouve de nombreuses questions sur l'usage du %n dans les formats de sortie. Si nombreux sont ceux qui savent ce que cela fait, il est étrange de constater que tout aussi nombreux sont ceux qui n'en voient pas l'usage. Il est vrai qu'à l'ère des GUI  les affichages en mode texte paraissent désuets, et pourtant…

Voici ce qu'en raconte les manuels:

%n : Print nothing, but write number of characters successfully written so far into an integer pointer parameter.

Donc ce n'est pas un paramètre de sortie mais une valeur calculée lors d'une sortie et qui est rangée dans un entier dont l'adresse est spécifiée en argument.

Le code suivant :

int l;
printf("Bonjour%n\n",&l);
printf("%d\n",l);

génère à l'exécution la sortie suivante :

Bonjour
7

La valeur 7 correspond au nombre de caractères écrits depuis le début du premier printf jusqu'au %n, soit 7. Bien entendu, il s'agit de tout ce qui a été écrit, et non de la chaîne du format elle-même, ainsi :

int l;
char *s = "Au revoir";
printf("%s%n\n",s&,l);
printf("%d\n",l);

génère la sortie :

Au revoir
9

Alors à quoi cela sert-il ? À formater on vous dit! N'oublions pas que le formatage comporte deux aspects :

  • le premier est la représentation d'une valeur d'un type donné (entier en hexa, ou octal par exemple),
  • le second est le placement de la représentation dans l'espace réservé à l'affichage de la valeur du type (calage à gauche, bourrage, etc.).


%n sert pour le formatage d'un troisième genre : la vue tabulaire, c'est-à-dire le placement relatif et contrôlé des différentes représentations.

Si je souhaite afficher un tableau, comment m'en sortir ? La première solution est de parier sur la fixité de certains paramètres du tableau, comme dans ce qui suit :

printf("Table | i+100 | i+200 |\n");
for (int i=0; i<10; i++) {
printf("i=%-3d | %5d | %5d |\n",i,i+100,i+200);
}
return 0;

et qui permet d'obtenir :

Table | i+100 | i+200 |
i=0   |   100 |   200 |
i=1   |   101 |   201 |
i=2   |   102 |   202 |
i=3   |   103 |   203 |
i=4   |   104 |   204 |
i=5   |   105 |   205 |
i=6   |   106 |   206 |
i=7   |   107 |   207 |
i=8   |   108 |   208 |
i=9   |   109 |   209 |

parfait, mais cet affichage ne fonctionne que parce que les tailles des différentes colonnes ont été calculées à l'avance! Que faire si l'entête du tableau est obtenu via des valeurs calculées ou obtenues depuis l'extérieur ?

char *f1 = "Table", *f2="i+100", *f3="i+200";
int l1, l2, l3;
l1 = strlen(f1);
l2 = strlen(f2);
l3 = strlen(f2);
printf("%s | %s | %s |\n",f1,f2,f3);
for (int i=0; i<10; i++) {
        printf("i=%*d | %*d | %*d |\n",
               -(l1-2),i,l2,i+100,l3,i+200);
}

On remarquera au passage : l'usage de l'étoile * qui permet en sortie de spécifier via une variable la largeur du champ, et l'usage d'une valeur négative pour la longueur du champ qui signifie un calage à gauche...

Reste que l'on doit encore écrire des bouts de code pour calculer les longueurs. C'est là que %n débarque :

printf("%s%n | %s%n | %s%n\n",f1,&c,f2,&c1,f3,&c2);
for (int i=0; i<10; i++) {
        printf("i=%*d | %*d | %*d\n",
               -(c-2),i,c1-c-3,i+100,c2-c1-3,i+200);
}

Je vous laisse tester avec différentes valeurs pour les champs d'entête de colonne (f1, f2, et f3), voici ce que j'ai obtenu :

Ma table magique | Premiere colonne | Seconde
i=0              |              100 |     200
i=1              |              101 |     201
i=2              |              102 |     202
i=3              |              103 |     203
i=4              |              104 |     204
i=5              |              105 |     205
i=6              |              106 |     206
i=7              |              107 |     207
i=8              |              108 |     208
i=9              |              109 |     209
DjiBee

dimanche 1 juin 2014

Linux : Numérotation des processeurs et topologie d'iceux

Comptons les processeurs, 

Y'a pas de thread chez nous, 

Y'en a chez la voisine... 

Besoin primaire

Sur des machines disposant de plusieurs processeurs, il arrive que l'on ait besoin pour (tenter d') assurer qu'un processus ait de bonnes performances, que l'on "épingle" le processus sur un processeur: en clair, on indique au système qu'il doit impérativement exécuter le dit processus sur le processeur de numéro N.

Si le processus épinglé a plusieurs threads logicielles, ou si l'on a plusieurs processus, on peut être amené à forcer l'exécution de ces différentes threads (ou processus) sur différents processeurs (numéros N, N+1, N+2...).

Facile donc ?

Posix nous fournit tout ce qu'il faut ? Euh non. Quand on a ce genre de besoins, il faut utiliser les outils spécifiques du système. Portabilité ? Ben, non. De toute manière, si vous faîtes ça pour des raisons d'amélioration de performances, votre travail est à faire (ou pour le moins à adapter) pour chaque système d'accueil.

Fixer un (des) processeur(s) d'exécution sur Linux

  • taskset est une commande qui permet de contraindre l'exécution d'un programme ou d'un processus sur un ou des processeurs spécifiés en paramètre.
  • Par programmation en C, on peut utiliser sched_affinity ou pthread_setaffinity_np.
Cliquer sur les liens ci-dessus pour obtenir les pages  manuels.

 Facile donc

Oui, il suffit d'utiliser les outils ci-dessus et le tour est joué.  :-)
Ça a pourtant l'air facile. On fixe des numéros de processeurs, en vérifiant que l'on nomme des processeurs existants de la machine (pour simplifier : de numéro inférieur au nombre de processeurs de la machine, que l'on peut obtenir sur Linux par la commande nproc).

En fait, et bien évidemment, c'est  souvent,  sinon le début des ennuis, tout au moins l'entrée dans un espace de complexité accrue. Tant pis pour vous. 

Un processeur = un processeur ?

Ce n'est malheureusement pas une question de philo. Ce n'est pas vraiment qu'il y ait des processeurs plus z'égaux que d'autres. Mais il faut quand même être très attentif. Je sais, tu sais, il sait, nous savons,... qu'il existe des processeurs multi-cœurs et des processeurs hyper-threadés. Pour simplifier, ne considérons que le monde x86 (32 et/ou 64 bits).

"Hiérarchie de processeurs"

On a donc des:
  • processeurs (puces avec plein de pattes qui s'enfichent dans des "sockets" sur la carte mère, et que du coup on appelle assez souvent sockets) qui contiennent
    • des cœurs (cores) que l'on peut parfois appeler abusivement processeurs qui eux-mêmes contiennent
      • des hyper-threads que l'on appelle souvent et abusivement des processeurs.
Pas question  ici de rentrer dans le détail des subtilités. Mais de manière très schématique:
  • les hyper-threads d'un même cœur partagent souvent les mêmes unités de calculs et les caches mémoires L1, L2, L3. 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).
  • Les cœurs d'une même puce peuvent partager des caches L3 et L2.
  • Des puces différentes ne partagent rien. Quoique....
Si on considère des machines un peu plus puissantes on va avoir en plus des machines de type NUMA (Non Uniform Memory Access) qui vont venir nous compliquer la tâche. Les temps d'accès à une adresse mémoire donnée ne sont pas identiques en fonction du processeur (puce) depuis lequel on référence cette adresse mémoire.

Problèmes de choix

Du coup,voilà quelques problèmes :
  • Si on met 2 processus (pthreads) sur deux cpu (hyper-threads) du même cœur, ils vont se partager (ou être en compétition pour) l'utilisation du cache L1. Mais surtout la puissance de calcul n'est pas forcément celle espérée.
  • Si on met 2 processus (pthreads) sur deux cpu sur des cœurs différents, en supposant qu'il n'y ait pas d'autres processus squattant les hyper-threads co-localisées sur ces cœurs, la puissance de calcul "disponible" devrait être meilleure. Mais les partages de caches étant différents, qui sait... 
Il ne s'agit pas ici, malheureusement, de fournir les clés permettant de répondre à ces questions. Déception. Consternation. Mais simplement de se dire que le placement des processus sur les cpu est une chose importante.  Du coup, il devient nécessaire de savoir si deux CPU de numéros respectifs N et M partagent ou ne partagent pas le même cœur, et/ou les mêmes caches.

De la numérotation à la topologie

Naïvement, on pourrait penser que si le cpu N°0 correspond à la première hyper-thread du premier cœur de la première puce, le cpu  N°1 serait logiquement la deuxième hyper-thread du premier cœur de la première puce...

Perdu!

Comment savoir alors ? Grâce à la commande  lscpu (voir la page manuel). Voici ce qu'elle indique sur une machine :

fa$ lscpu
Architecture:          x86_64
CPU op-mode(s):        32-bit, 64-bit
Byte Order:            Little Endian
CPU(s):                24
On-line CPU(s) list:   0-23
Thread(s) per core:    2
Core(s) per socket:    6
CPU socket(s):         2
NUMA node(s):          2
Vendor ID:             GenuineIntel
CPU family:            6
Model:                 45
Stepping:              7
CPU MHz:               1901.000
BogoMIPS:              3799.47
Virtualization:        VT-x
L1d cache:             32K
L1i cache:             32K
L2 cache:              256K
L3 cache:              15360K
NUMA node0 CPU(s):     0,2,4,6,8,10,12,14,16,18,20,22
NUMA node1 CPU(s):     1,3,5,7,9,11,13,15,17,19,21,23

fa$
Bon , pas très informatif. Ah si notre machine a :
  • 2 sockets (puces)
  • qui sont équipées chacune de 6 cœurs
  •  qui sont chacun "hyper-threadé".
On a donc 24 processeurs à disposition.   Apparemment notre machine a deux nœuds NUMA : l'accès depuis un des processeurs  du nœud 0 (les cpu de numéro pair) à la mémoire  du nœud 0 sera plus rapide qu'un accès à la mémoire du nœud 1 depuis ces mêmes cpu de numéro pair.

En utilisant l'argument -p, on obtient un résultat difficilement lisible mais beaucoup plus instructif:
fa$ lscpu -p
# The following is the parsable format, which can be fed to other
# programs. Each different item in every column has an unique ID
# starting from zero.
# CPU,Core,Socket,Node,,L1d,L1i,L2,L3
0,0,0,0,,0,0,0,0
1,1,1,1,,1,1,1,1
2,2,0,0,,2,2,2,0
3,3,1,1,,3,3,3,1
4,4,0,0,,4,4,4,0
5,5,1,1,,5,5,5,1
6,6,0,0,,6,6,6,0
7,7,1,1,,7,7,7,1
8,8,0,0,,8,8,8,0
9,9,1,1,,9,9,9,1
10,10,0,0,,10,10,10,0
11,11,1,1,,11,11,11,1
12,0,0,0,,0,0,0,0
13,1,1,1,,1,1,1,1
14,2,0,0,,2,2,2,0
15,3,1,1,,3,3,3,1
16,4,0,0,,4,4,4,0
17,5,1,1,,5,5,5,1
18,6,0,0,,6,6,6,0
19,7,1,1,,7,7,7,1
20,8,0,0,,8,8,8,0
21,9,1,1,,9,9,9,1
22,10,0,0,,10,10,10,0
23,11,1,1,,11,11,11,1

fa$

On voit ici que ce sont les cpu N°0 et N°12 qui sont "co-localisés" sur le même cœur de la même socket, et qu'ils partagent les mêmes caches L1, L2, L3...

Voilà!

"Petits compléments"

Si l'envie vous prenait de plonger plus avant dans les problèmes de topologie des processeurs et caches, prenez la peine de lire :

vendredi 2 mai 2014

Unix process: To zombie or not to zombie?

 

 Dialogue imaginaire:

  • Robert:  "Au secours, un processus zombie!"
  • Arturo:  "T'es fou! Je ne vois qu'un crochetage!"
  • Jean-Baptiste:  "Crochetage, tu parles... Jardinage sauvage, oui."

Au fait!

fa$ ps -p 11025 -f 
UID      PID   PPID  C STIME TTY   TIME     CMD 
francois 11025 11024 0 16:33 pts/2 00:00:00 [arturo] 
fa$

Donc? Ce processus (sur un système Ubuntu) est-il un zombie ou non ? On ne parle pas ici de l'utilisateur mais bien du processus. Il arrive donc qu'en présence d'une trace comme celle montrée ci-dessus, Robert s'exclame : "Un zombie !"

Zombie

Un processus zombie est un processus qui a terminé son exécution via exit() ou des moyens plus violents, mais dont le père n'a pas encore ouvert le faire-part de décès: en bref, le père n'a pas invoqué l'appel wait(). Il y d'autres moyens à la disposition du processus père.

Usine à zombie

Voici un petit programme qui crée entre 1 et 16 zombies, puis attend 3 minutes avant de disparaître. Les zombies disparaîtront aussi, parce que leur père ayant cessé son existence en ignorant la leur, les zombies orphelins, se retrouveront rattachés au processus 1 (init) qui les fera passer de zombie à inexistant.

fa$ cat zombie_factory.c
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char** argv)
{
    int pid;
    unsigned long nb = 1;

    if (argc >= 2) {
        nb = strtol(argv[1], NULL, 10);
        if (nb > 16) nb = 16;
    }
    for(; nb > 0; nb--) {
        pid = fork();
        if (pid == -1) {
          perror("Cannot fork");
          exit(1);
        }
        if (pid == 0) exit(0);
    }
    sleep(180);
    return 0;
}

Robert aurait raison?

Si on lance ce processus et que l'on examine le résultat d'un ps:

fa$ ./zombie_factory 4 &
[2] 11284

fa$ ps -f
UID        PID  PPID  C STIME TTY          TIME CMD
francois 11284  2132  0 11:39 pts/2    00:00:00 ./zombie_factory 4
francois 11285 11284  0 11:39 pts/2    00:00:00 [zombie_factory] <defunct>
francois 11286 11284  0 11:39 pts/2    00:00:00 [zombie_factory] <defunct>
francois 11287 11284  0 11:39 pts/2    00:00:00 [zombie_factory] <defunct>
francois 11288 11284  0 11:39 pts/2    00:00:00 [zombie_factory] <defunct>
francois 11289  2132  0 11:39 pts/2    00:00:00 ps -f

fa$

Ah, oui les processus décédés ont bien leur nom de commande entre crochets. mais ils sont aussi marqués comme <defunct>. Notre premier processus [arturo] n'avait pas la mention <defunct>. Erreur ou oubli de la morgue?

Zombie or not Zombie?

Si on ajoute l'indicateur -l à la commande ps, on obtient alors aussi l'état (status) du processus, en deuxième colonne. L'état de notre processus est "S" : dormant (sleeping).

fa$ ps -p 11025 -fl
F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
0 S francois 11025 11024  0  80   0 -   501 hrtime 11:49 pts/2    00:00:00 [arturo]
fa$

L'état d'un processus zombie est normalement "Z". Vérifions:

fa$ ./zombie_factory &
[2] 11321

F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
1 Z francois 11322 11321  0  80   0 -     0 exit   12:02 pts/2    00:00:00 [zombie_factory] <defunct>
fa$

Donc...

Robert a tort

Notre processus [arturo] n'est pas en état "Z" mais en état "S". Il ne s'agit donc pas d'un zombie. Désolé Robert. Mais pourquoi son nom apparaît-il entre crochets?  RTFM !

Extrait de la page manuel ps
...
Modifications to the arguments may be shown. 
... Sometimes the process args will be
unavailable; when this happens, ps will instead print
the executable name in brackets
 

Entrée en scène d'Arturo

Donc, si on modifie les arguments, ou si les arguments sont inaccessibles... les modifications apportées seront visibles, ou le nom du processus sera affiché entre crochet.

Commençons par essayer de rendre les arguments inaccessibles... Ben, il n'y a qu'à pas en passer lors de l'exec. Ce n'est après tout qu'une convention de mettre le nom de la commande en argv[0] et non une obligation. Mettons un peu de désordre dans les conventions.
J´ai fantaisie de mett´ dans notre vie Un p´tit grain de fantaisie! Youpi, Youpi´ (B. Lapointe)
fa$ cat arturo.c
 

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <signal.h>

int main(int argc, char** argv)
{
    int pid;
    int res;
    if (argc >= 1) {
        pid = fork();
        if (pid == -1) {
            perror("Cannot fork");
            exit(1);
        }
        if (pid == 0) {
            res = execve(argv[0], NULL, NULL);
            perror("Cannot exec!");
        }
        sleep(1);
        system("ps -fl");
        kill(pid, SIGKILL);
        return 0;
    }
    sleep(180);
    return 0;
}

Le processus ci-dessus va créer un fils qui va faire exec du même programme, mais sans recevoir aucun argument.

fa$ ./arturo
F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
0 S francois 11473  2132  0  80   0 -   501 wait   16:39 pts/2    00:00:00 ./arturo
0 S francois 11474 11473  0  80   0 -   501 hrtime 16:39 pts/2    00:00:00 [arturo]

fa$

Donc, quand les arguments ne sont pas disponibles (dans la pile utilisateur) notre processus apparaît effectivement avec son nom "crocheté", mais n'est en aucun cas un zombie.

Note: Le programme ci-dessus ne fonctionne pas sur Solaris (sur la version de Solaris sur laquelle j'ai essayée) : l'appel exec renvoie EFAULT. Il faut absolument passer comme argv un tableau contenant un pointeur NULL. Ce n'est apparemment pas le cas pour le troisième paramètre de execenvp...
Comprend qui peut ou comprend qui veut  (B. Lapointe)

Le Jardinier sauvage

Puisque les arguments sont dans la pile utilisateur, que se passe-t-il si volontairement ou par mégarde, on vient piétiner ces plate-bandes?

Essayons!
fa$ cat arturo.c
 

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <signal.h>

int main(int argc, char** argv)
{
    int pid;
    int res;
    if (argc >= 1) {
        pid = fork();
        if (pid == -1) {
            perror("Cannot fork");
            exit(1);
        }
        if (pid == 0) {
            res = execve(argv[0], NULL, NULL);
            perror("Cannot exec!");
        }

        int lg = strlen(argv[0]) / 2;
        for(; lg >= 0; lg--) argv[0][lg] = '\0';
        argv[0] = NULL;

        sleep(1);
        system("ps -fl");
        kill(pid, SIGKILL);
        return 0;
    }
    sleep(180);
    return 0;
}

Dans notre programme précédent, dans le cas du père, on efface avec des caractères nuls, la moitié de la chaîne argv[0].  Et surcroît de sauvagerie, on met le pointeur argv[0] à NULL.

Action.

fa$ ./arturo
F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
0 S francois 11485  2132  0  80   0 -   501 wait   16:56 pts/2    00:00:00      uro
0 S francois 11486 11485  0  80   0 -   501 hrtime 16:56 pts/2    00:00:00 [arturo]

fa$

Euh? Le père voit son nom de commande tronqué. C'est normal. on vient de labourer joyeusement la chaîne de caractères. Mais il semblerait que les caractères nuls insérés soient remplacés par des espaces.

En fait, cela semble dépendre des versions de la commande ps.
  • La version procps-ng version 3.3.3 avec laquelle j'ai effectuée ces essais montre le nom tronqué.
  • La version procps version 3.2.8 donne le résultat ci-dessous. Hop, invoquons Harry Houdini, un processus anonyme!
    • Bonsoir, je m'appelle "       " 
    • Bonsoir "    "
    • J'ai habitude de jardiner sauvagement les données dans ma pile.
fa$ ./arturo
F S UID        PID  PPID  C PRI  NI ADDR SZ WCHAN  STIME TTY          TIME CMD
0 S francois 17178  3131  0  80   0 -   459 wait   17:03 pts/0    00:00:00          <=== Là il n'y a rien!
0 S francois 17179 17178  0  80   0 -   459 hrtime 17:03 pts/0    00:00:00 [arturo]

fa$

Sur AIX le processus anonyme apparaît avec le nom passé en premier argument de exec.

Faut-il une conclusion?

To zombie or not to zombie, là n'est plus la question.
Pour se débarrasser des zombies, une requête sue le web vous donnera des tonnes de réponses. Des bonnes, des mauvaises, des étranges.  Soyez "écolos" faîtes le tri.

Générique

 Par ordre d'entrée en scène:
  • Robert Neville
  • Arturo Brachetti
  • Jean-Batpiste de La Quintinie
  • Harry Houdini