Pourquoi itérer sur un fichier deux fois plus vite que de le lire en mémoire et de calculer deux fois?

26

Je compare ce qui suit

tail -n 1000000 stdout.log | grep -c '"success": true'
tail -n 1000000 stdout.log | grep -c '"success": false'

avec ce qui suit

log=$(tail -n 1000000 stdout.log)
echo "$log" | grep -c '"success": true'
echo "$log" | grep -c '"success": false'

et étonnamment, la seconde prend presque 3 fois plus de temps que la première. Ça devrait être plus rapide, non?

phunehehe
la source
Serait-ce parce que la deuxième solution, le contenu du fichier est lu 3 fois, et seulement deux fois dans le premier exemple?
Laurent C.
4
Au moins dans le deuxième exemple, votre $( command substitution )n'est pas diffusé. Tout le reste se produit via des canaux simultanément, mais dans le deuxième exemple, vous devez attendre la log=fin de l'opération. Essayez-le avec << ICI \ n $ {log = $ (commande)} \ n ICI - voyez ce que vous obtenez.
mikeserv
Dans le cas de fichiers extrêmement volumineux, de machines soumises à des contraintes de mémoire ou de plusieurs éléments à greputiliser, vous pouvez constater une accélération de teesorte que le fichier ne soit définitivement lu qu'une seule fois. cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
Matt
@LaurentC., Non, il n'est lu qu'une seule fois dans le deuxième exemple. Il n'y a qu'un seul appel à la queue.
psusi
Maintenant, comparez-les à tail -n 10000 | fgrep -c '"success": true'et faux.
kojiro

Réponses:

11

D'une part, la première méthode appelle taildeux fois, elle doit donc faire plus de travail que la deuxième méthode qui ne le fait qu'une seule fois. D'un autre côté, la deuxième méthode doit copier les données dans le shell, puis revenir en arrière, elle doit donc faire plus de travail que la première version où elle tailest directement canalisée grep. La première méthode a un avantage supplémentaire sur une machine multiprocesseur: greppeut fonctionner en parallèle avec tail, alors que la deuxième méthode est strictement sérialisée, d'abord tail, ensuite grep.

Il n'y a donc aucune raison évidente pour laquelle l'un devrait être plus rapide que l'autre.

Si vous voulez voir ce qui se passe, regardez ce que le système appelle le shell. Essayez également avec différentes coquilles.

strace -t -f -o 1.strace sh -c '
  tail -n 1000000 stdout.log | grep "\"success\": true" | wc -l;
  tail -n 1000000 stdout.log | grep "\"success\": false" | wc -l'

strace -t -f -o 2-bash.strace bash -c '
  log=$(tail -n 1000000 stdout.log);
  echo "$log" | grep "\"success\": true" | wc -l;
  echo "$log" | grep "\"success\": true" | wc -l'

strace -t -f -o 2-zsh.strace zsh -c '
  log=$(tail -n 1000000 stdout.log);
  echo "$log" | grep "\"success\": true" | wc -l;
  echo "$log" | grep "\"success\": true" | wc -l'

Avec la méthode 1, les principales étapes sont:

  1. tail lit et cherche à trouver son point de départ.
  2. tailécrit des morceaux de 4096 octets qui greplisent aussi vite qu'ils sont produits.
  3. Répétez l'étape précédente pour la deuxième chaîne de recherche.

Avec la méthode 2, les principales étapes sont:

  1. tail lit et cherche à trouver son point de départ.
  2. tail écrit des blocs de 4096 octets qui bash lit 128 octets à la fois, et zsh lit 4096 octets à la fois.
  3. Bash ou zsh écrit des morceaux de 4096 octets qui greplisent aussi vite qu'ils sont produits.
  4. Répétez l'étape précédente pour la deuxième chaîne de recherche.

Les morceaux de 128 octets de Bash lors de la lecture de la sortie de la substitution de commandes la ralentissent considérablement; zsh sort à peu près aussi vite que la méthode 1 pour moi. Votre kilométrage peut varier en fonction du type et du nombre de CPU, de la configuration du planificateur, des versions des outils impliqués et de la taille des données.

Gilles 'SO- arrête d'être méchant'
la source
La taille de page de la figure 4k dépend-elle? Je veux dire, queue et zsh sont-ils tous les deux des syscalls mmaping? (Peut-être que c'est une terminologie incorrecte, mais j'espère que non ...) Qu'est-ce que bash fait différemment?
mikeserv
C'est spot sur Gilles! Avec zsh, la deuxième méthode est légèrement plus rapide sur ma machine.
phunehehe
Excellent travail Gilles, tks.
X Tian
@mikeserv Je n'ai pas regardé la source pour voir comment ces programmes choisissent la taille. Les raisons les plus probables de voir 4096 seraient une constante intégrée ou la st_blksizevaleur d'un tuyau, qui est 4096 sur cette machine (et je ne sais pas si c'est parce que c'est la taille de la page MMU). Le 128 de Bash devrait être une constante intégrée.
Gilles 'SO- arrête d'être méchant'
@ Gilles, merci pour la réponse réfléchie. J'ai récemment été curieux de connaître la taille des pages.
mikeserv
26

J'ai fait le test suivant et sur mon système, la différence résultante est environ 100 fois plus longue pour le deuxième script.

Mon fichier est une sortie strace appelée bigfile

$ wc -l bigfile.log 
1617000 bigfile.log

Scripts

xtian@clafujiu:~/tmp$ cat p1.sh
tail -n 1000000 bigfile.log | grep '"success": true' | wc -l
tail -n 1000000 bigfile.log | grep '"success": false' | wc -l

xtian@clafujiu:~/tmp$ cat p2.sh
log=$(tail -n 1000000 bigfile.log)
echo "$log" | grep '"success": true' | wc -l
echo "$log" | grep '"success": true' | wc -l

Je n'ai pas de correspondance pour le grep, donc rien n'est écrit dans le dernier tube wc -l

Voici les horaires:

xtian@clafujiu:~/tmp$ time bash p1.sh
0
0

real    0m0.381s
user    0m0.248s
sys 0m0.280s
xtian@clafujiu:~/tmp$ time bash p2.sh
0
0

real    0m46.060s
user    0m43.903s
sys 0m2.176s

J'ai donc exécuté à nouveau les deux scripts via la commande strace

strace -cfo p1.strace bash p1.sh
strace -cfo p2.strace bash p2.sh

Voici les résultats des traces:

$ cat p1.strace 
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 97.24    0.508109       63514         8         2 waitpid
  1.61    0.008388           0     84569           read
  1.08    0.005659           0     42448           write
  0.06    0.000328           0     21233           _llseek
  0.00    0.000024           0       204       146 stat64
  0.00    0.000017           0       137           fstat64
  0.00    0.000000           0       283       149 open
  0.00    0.000000           0       180         8 close
...
  0.00    0.000000           0       162           mmap2
  0.00    0.000000           0        29           getuid32
  0.00    0.000000           0        29           getgid32
  0.00    0.000000           0        29           geteuid32
  0.00    0.000000           0        29           getegid32
  0.00    0.000000           0         3         1 fcntl64
  0.00    0.000000           0         7           set_thread_area
------ ----------- ----------- --------- --------- ----------------
100.00    0.522525                149618       332 total

Et p2.strace

$ cat p2.strace 
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 75.27    1.336886      133689        10         3 waitpid
 13.36    0.237266          11     21231           write
  4.65    0.082527        1115        74           brk
  2.48    0.044000        7333         6           execve
  2.31    0.040998        5857         7           clone
  1.91    0.033965           0    705681           read
  0.02    0.000376           0     10619           _llseek
  0.00    0.000000           0       248       132 open
...
  0.00    0.000000           0       141           mmap2
  0.00    0.000000           0       176       126 stat64
  0.00    0.000000           0       118           fstat64
  0.00    0.000000           0        25           getuid32
  0.00    0.000000           0        25           getgid32
  0.00    0.000000           0        25           geteuid32
  0.00    0.000000           0        25           getegid32
  0.00    0.000000           0         3         1 fcntl64
  0.00    0.000000           0         6           set_thread_area
------ ----------- ----------- --------- --------- ----------------
100.00    1.776018                738827       293 total

Une analyse

Sans surprise, dans les deux cas, la plupart du temps est passé à attendre la fin d'un processus, mais p2 attend 2,63 fois plus longtemps que p1, et comme d'autres l'ont mentionné, vous commencez tard dans p2.sh.

Alors maintenant oubliez le waitpid, ignorez la %colonne et regardez la colonne des secondes sur les deux traces.

Le plus grand temps que p1 passe la plupart de son temps en lecture est probablement compréhensible, car il y a un gros fichier à lire, mais p2 passe 28,82 fois plus en lecture que p1. - bashne s'attend pas à lire un fichier aussi volumineux dans une variable et lit probablement le tampon à la fois, se divise en lignes, puis en obtient un autre.

le nombre de lectures p2 est de 705k contre 84k pour p1, chaque lecture nécessitant un changement de contexte dans l'espace du noyau et à nouveau. Près de 10 fois le nombre de lectures et de changements de contexte.

Le temps d'écriture p2 passe 41,93 fois plus longtemps en écriture que p1

le nombre d'écritures p1 fait plus d'écrits que p2, 42k vs 21k, mais ils sont beaucoup plus rapides.

Probablement à cause des echolignes dans greppar opposition aux tampons d'écriture de queue.

De plus , p2 passe plus de temps en écriture qu'en lecture, p1 est l'inverse!

Autre facteur Regardez le nombre d' brkappels système: p2 passe 2,42 fois plus de temps qu'il ne le fait à lire! En p1 (il ne s'enregistre même pas). brkest lorsque le programme a besoin d'étendre son espace d'adressage car suffisamment n'a pas été alloué initialement, cela est probablement dû au fait que bash doit lire ce fichier dans la variable, et ne s'attend pas à ce qu'il soit si grand, et comme @scai l'a mentionné, si le le fichier devient trop volumineux, même cela ne fonctionnerait pas.

tailest probablement un lecteur de fichiers assez efficace, car c'est ce pour quoi il a été conçu, il mappe probablement le fichier et recherche les sauts de ligne, permettant ainsi au noyau d'optimiser les E / S. bash n'est pas aussi bon en termes de temps passé à lire et à écrire.

p2 passe 44 ms et 41 ms cloneet execvce n'est pas une quantité mesurable pour p1. Probablement lire bash et créer la variable à partir de la queue.

Enfin, le Totals p1 exécute ~ 150k appels système vs p2 740k (4,93 fois plus).

En éliminant waitpid, p1 passe 0,014416 secondes à exécuter des appels système, p2 0,439132 secondes (30 fois plus).

Il semble donc que p2 passe la plupart du temps dans l'espace utilisateur à ne rien faire, sauf à attendre que les appels système se terminent et que le noyau réorganise la mémoire, p1 effectue plus d'écritures, mais est plus efficace et entraîne une charge système nettement inférieure, et est donc plus rapide.

Conclusion

Je n'essaierais jamais de me soucier du codage via la mémoire lors de l'écriture d'un script bash, cela ne veut pas dire que vous n'essayez pas d'être efficace.

tailest conçu pour faire ce qu'il fait, c'est probablement memory mapsle fichier pour qu'il soit efficace à lire et permette au noyau d'optimiser les E / S.

Une meilleure façon d'optimiser votre problème pourrait être de commencer greppar les lignes de «succès»: puis de compter les vrais et les faux, grepa une option de comptage qui évite encore une fois wc -l, ou mieux encore, de awkdiriger la queue vers et de compter les vraies et fausses simultanément. p2 non seulement prend beaucoup de temps mais ajoute de la charge au système pendant que la mémoire est mélangée avec brks.

X Tian
la source
2
TL; DR: malloc (); si vous pouviez dire à $ log combien il devait être grand et pouvoir l'écrire rapidement en une seule opération sans réallocations, ce serait probablement aussi rapide.
Chris K
5

En fait, la première solution lit également le fichier en mémoire! Cela s'appelle la mise en cache et est effectuée automatiquement par le système d'exploitation.

Et comme déjà correctement expliqué par mikeserv, la première solution s'exécute grep pendant la lecture du fichier tandis que la seconde solution l'exécute après la lecture du fichier tail.

La première solution est donc plus rapide en raison de diverses optimisations. Mais cela ne doit pas toujours être vrai. Pour les très gros fichiers que le système d'exploitation décide de ne pas mettre en cache, la deuxième solution pourrait devenir plus rapide. Mais notez que pour des fichiers encore plus volumineux qui ne rentrent pas dans votre mémoire, la deuxième solution ne fonctionnera pas du tout.

scai
la source
3

Je pense que la principale différence est très simplement que echoc'est lent. Considère ceci:

$ time (tail -n 1000000 foo | grep 'true' | wc -l; 
        tail -n 1000000 foo | grep 'false' | wc -l;)
666666
333333

real    0m0.999s
user    0m1.056s
sys     0m0.136s

$ time (log=$(tail -n 1000000 foo); echo "$log" | grep 'true' | wc -l; 
                                    echo "$log" | grep 'false' | wc -l)
666666
333333

real    0m4.132s
user    0m3.876s
sys     0m0.468s

$ time (tail -n 1000000 foo > bb;  grep 'true' bb | wc -l; 
                                   grep 'false' bb | wc -l)
666666
333333

real    0m0.568s
user    0m0.512s
sys     0m0.092s

Comme vous pouvez le voir ci-dessus, l'étape qui prend du temps est l'impression des données. Si vous redirigez simplement vers un nouveau fichier et effectuez une grep, cela est beaucoup plus rapide lorsque vous ne lisez le fichier qu'une seule fois.


Et comme demandé, avec une chaîne ici:

 $ time (log=$(tail -n 1000000 foo); grep 'true' <<< $log | wc -l; 
                                     grep 'false' <<< $log | wc -l  )
1
1

real    0m7.574s
user    0m7.092s
sys     0m0.516s

Celui-ci est encore plus lent, probablement parce que la chaîne ici concatène toutes les données sur une longue ligne et cela ralentira grep:

$ tail -n 1000000 foo | (time grep -c 'true')
666666

real    0m0.500s
user    0m0.472s
sys     0m0.000s

$ tail -n 1000000 foo | perl -pe 's/\n/ /' | (time grep -c 'true')
1

real    0m1.053s
user    0m0.048s
sys     0m0.068s

Si la variable est citée de façon à ce qu'aucun fractionnement ne se produise, les choses sont un peu plus rapides:

 $ time (log=$(tail -n 1000000 foo); grep 'true' <<< "$log" | wc -l; 
                                     grep 'false' <<< "$log" | wc -l  )
666666
333333

real    0m6.545s
user    0m6.060s
sys     0m0.548s

Mais toujours lent car l'étape de limitation de débit imprime les données.

terdon
la source
Pourquoi n'essayez-vous pas, <<<il serait intéressant de voir si cela fait une différence.
Graeme
3

J'ai aussi essayé ça ... Tout d'abord, j'ai construit le fichier:

printf '"success": "true"
        "success": "true"
        "success": "false"
        %.0b' `seq 1 500000` >|/tmp/log

Si vous exécutez ce qui précède vous-même, vous devriez trouver 1,5 million de lignes /tmp/logavec un rapport 2: 1 de "success": "true"lignes à "success": "false"lignes.

La prochaine chose que j'ai faite a été de faire des tests. J'ai exécuté tous les tests via un proxy sh, timeje n'aurais donc qu'à regarder un seul processus - et pourrais donc afficher un seul résultat pour l'ensemble du travail.

Cela semble être le plus rapide, même s'il ajoute un deuxième descripteur de fichier et tee,bien que je pense pouvoir expliquer pourquoi:

    time sh <<-\CMD
        . <<HD /dev/stdin | grep '"success": "true"' | wc -l
            tail -n 1000000 /tmp/log | { tee /dev/fd/3 |\
                grep '"success": "false"' |\
                    wc -l 1>&2 & } 3>&1 &
        HD
    CMD
666666
333334
sh <<<''  0.11s user 0.08s system 84% cpu 0.224 total

Voici votre premier:

    time sh <<\CMD
        tail -n 1000000 /tmp/log | grep '"success": "true"' | wc -l
        tail -n 1000000 /tmp/log | grep '"success": "false"' | wc -l
    CMD

666666
333334
sh <<<''  0.31s user 0.17s system 148% cpu 0.323 total

Et votre deuxième:

    time sh <<\CMD
        log=$(tail -n 1000000 /tmp/log)
        echo "$log" | grep '"success": "true"' | wc -l
        echo "$log" | grep '"success": "false"' | wc -l
    CMD
666666
333334
sh <<<''  2.12s user 0.46s system 108% cpu 2.381 total

Vous pouvez voir que dans mes tests, il y avait plus d'une différence de vitesse de 3 * lors de la lecture dans une variable comme vous l'avez fait.

Je pense qu'une partie de cela est qu'une variable shell doit être divisée et gérée par le shell lors de sa lecture - ce n'est pas un fichier.

A here-documentd'autre part, à toutes fins utiles, est un file- un defile descriptor, toute façon. Et comme nous le savons tous, Unix fonctionne avec des fichiers.

Ce qui m'intéresse le plus, here-docsc'est que vous pouvez manipuler leur file-descriptors- comme une ligne droite |pipe- et les exécuter. Ceci est très pratique car il vous permet un peu plus de liberté pour pointer |pipeoù vous le souhaitez.

Je devais teele tailcar le premier grepmange le here-doc |pipeet il n'y a plus rien à lire pour le second. Mais depuis que je |pipeddans /dev/fd/3et pris à nouveau passer à >&1 stdout,elle n'a pas beaucoup d' importance. Si vous en utilisez grep -cautant que d'autres recommandent:

    time sh <<-\CMD
        . <<HD /dev/stdin | grep -c '"success": "true"'
            tail -n 1000000 /tmp/log | { tee /dev/fd/3 |\
                grep -c '"success": "false"' 1>&2 & } 3>&1 &
        HD
    CMD
666666
333334
sh <<<''  0.07s user 0.04s system 62% cpu 0.175 total

C'est encore plus rapide.

Mais quand je le lance sans . sourcingle heredocje ne peux fond avec succès le premier processus pour les exécuter entièrement en même temps. Le voici sans le fonder complètement:

    time sh <<\CMD
        tail -n 1000000 /tmp/log | { tee /dev/fd/3 |\
            grep -c '"success": "true"' 1>&2 & } 3>&1 |\
                grep -c '"success": "false"'
    CMD
666666
333334
sh <<<''  0.10s user 0.08s system 109% cpu 0.165 total

Mais quand j'ajoute le &:

    time sh <<\CMD
        tail -n 1000000 /tmp/log | { tee /dev/fd/3 |\
            grep -c '"success": "true"' 1>&2 & } 3>&1 & |\
                grep -c '"success": "false"'
    CMD
sh: line 2: syntax error near unexpected token `|'

Pourtant, la différence ne semble être que de quelques centièmes de seconde, du moins pour moi, alors prenez-la comme vous voulez.

Quoi qu'il en soit, la raison pour laquelle il s'exécute plus rapidement teeest parce que les deux grepss'exécutent en même temps avec une seule invocation de tail. teedoublons le fichier pour nous et le divise au deuxième grepprocessus tout en continu - tout s'exécute en même temps du début à la fin, donc ils tous finissent à peu près au même moment aussi.

Revenons donc à votre premier exemple:

    tail | grep | wc #wait til finished
    tail | grep | wc #now we're done

Et votre deuxième:

    var=$( tail ) ; #wait til finished
    echo | grep | wc #wait til finished
    echo | grep | wc #now we're done

Mais lorsque nous divisons notre entrée et exécutons nos processus simultanément:

          3>&1  | grep #now we're done
              /        
    tail | tee  #both process together
              \  
          >&1   | grep #now we're done
mikeserv
la source
1
+1 mais votre dernier test est mort avec une erreur de syntaxe, je ne pense pas que les temps soient corrects :)
terdon
@terdon Ils pourraient se tromper - je faisais remarquer qu'il est mort. J'ai montré la différence entre le & et le non & - lorsque vous l'ajoutez, le shell se fâche. Mais j'ai fait beaucoup de copier / coller, donc j'aurais peut-être foiré un ou deux, mais je pense qu'ils vont bien ...
mikeserv
sh: ligne 2: erreur de syntaxe près du jeton inattendu `| '
terdon
@terdon Ouais - "Je ne parviens pas à mettre en arrière-plan le premier processus pour les exécuter simultanément. Vous voyez?" Le premier n'est pas en arrière-plan, mais lorsque j'ajoute & pour tenter de le faire, "jeton inattendu". Quand je . source l'hérédoc je peux utiliser le &.
mikeserv