Y a-t-il un problème avec mon script ou Bash est-il beaucoup plus lent que Python?

29

Je testais la vitesse de Bash et Python en exécutant une boucle 1 milliard de fois.

$ cat python.py
#!/bin/python
# python v3.5
i=0;
while i<=1000000000:
    i=i+1;

Code Bash:

$ cat bash2.sh
#!/bin/bash
# bash v4.3
i=0
while [[ $i -le 1000000000 ]]
do
let i++
done

En utilisant la timecommande, j'ai découvert que le code Python ne prend que 48 secondes pour terminer tandis que le code Bash a pris plus d'une heure avant de tuer le script.

Pourquoi cela est-il ainsi? Je m'attendais à ce que Bash soit plus rapide. Y a-t-il un problème avec mon script ou Bash est-il vraiment beaucoup plus lent avec ce script?

Edward Torvalds
la source
49
Je ne sais pas trop pourquoi vous vous attendiez à ce que Bash soit plus rapide que Python.
Kusalananda
9
@MatijaNalis non, vous ne pouvez pas! Le script est chargé en mémoire, l'édition du fichier texte à partir duquel il a été lu (le fichier de script) n'aura absolument aucun effet sur le script en cours d'exécution. Une bonne chose aussi, bash est déjà assez lent sans avoir à ouvrir et relire un fichier à chaque fois qu'une boucle est exécutée!
terdon
4
Bash lit le fichier ligne par ligne lors de son exécution, mais il se souvient de ce qu'il a lu s'il revient à cette ligne (car il est dans une boucle ou une fonction). L'affirmation originale concernant la relecture de chaque itération n'est pas vraie, mais les modifications apportées aux lignes encore à atteindre seront effectives. Une démonstration intéressante: créer un fichier contenant echo echo hello >> $0, et l'exécuter.
Michael Homer
3
@MatijaNalis ah, OK, je peux comprendre ça. C'est l'idée de changer une boucle de course qui m'a lancé. Vraisemblablement, chaque ligne est lue séquentiellement et seulement après la fin de la dernière. Cependant, une boucle est traitée comme une seule commande et sera lue dans son intégralité, donc la modifier n'affectera pas le processus en cours. Distinction intéressante cependant, j'avais toujours supposé que le script entier était chargé en mémoire avant exécution. Merci de l'avoir signalé!
terdon

Réponses:

17

Il s'agit d'un bogue connu dans bash; voir la page de manuel et rechercher "BUGS":

BUGS
       It's too big and too slow.

;)


Pour une excellente introduction aux différences conceptuelles entre les scripts shell et d'autres langages de programmation, je recommande fortement la lecture:

Les extraits les plus pertinents:

Les shells sont un langage de niveau supérieur. On peut dire que ce n'est même pas une langue. Ils sont avant tous les interprètes de ligne de commande. Le travail est effectué par les commandes que vous exécutez et le shell est uniquement destiné à les orchestrer.

...

IOW, dans des shells, en particulier pour traiter du texte, vous invoquez le moins d'utilitaires possible et les faites coopérer à la tâche, pas exécutez des milliers d'outils en séquence en attendant que chacun démarre, s'exécute, nettoie avant d'exécuter le suivant.

...

Comme indiqué précédemment, l'exécution d'une commande a un coût. Un coût énorme si cette commande n'est pas intégrée, mais même si elles sont intégrées, le coût est élevé.

Et les shells n'ont pas été conçus pour fonctionner comme ça, ils n'ont aucune prétention à être des langages de programmation performants. Ils ne le sont pas, ce ne sont que des interprètes en ligne de commande. Donc, peu d'optimisation a été faite sur ce front.


N'utilisez pas de grandes boucles dans les scripts shell.

Caractère générique
la source
54

Les boucles de shell sont lentes et les bash sont les plus lentes. Les obus ne sont pas destinés à effectuer un travail lourd en boucles. Les shells sont destinés à lancer quelques processus externes optimisés sur des lots de données.


Quoi qu'il en soit, j'étais curieux de savoir comment les boucles shell se comparent, j'ai donc fait un petit point de repère:

#!/bin/bash

export IT=$((10**6))

echo POSIX:
for sh in dash bash ksh zsh; do
    TIMEFORMAT="%RR %UU %SS $sh"
    time $sh -c 'i=0; while [ "$IT" -gt "$i" ]; do i=$((i+1)); done'
done


echo C-LIKE:
for sh in bash ksh zsh; do
    TIMEFORMAT="%RR %UU %SS $sh"
    time $sh -c 'for ((i=0;i<IT;i++)); do :; done'
done

G=$((10**9))
TIMEFORMAT="%RR %UU %SS 1000*C"
echo 'int main(){ int i,sum; for(i=0;i<IT;i++) sum+=i; printf("%d\n", sum); return 0; }' |
   gcc -include stdio.h -O3 -x c -DIT=$G - 
time ./a.out

( Détails:

  • Processeur: Intel (R) Core (TM) i5 CPU M 430 @ 2,27 GHz
  • ksh: version sh (AT&T Research) 93u + 2012-08-01
  • bash: GNU bash, version 4.3.11 (1) -release (x86_64-pc-linux-gnu)
  • zsh: zsh 5.2 (x86_64-unknown-linux-gnu)
  • tiret: 0.5.7-4ubuntu1

)

Les résultats (abrégés) (temps par itération) sont:

POSIX:
5.8 µs  dash
8.5 µs ksh
14.6 µs zsh
22.6 µs bash

C-LIKE:
2.7 µs ksh
5.8 µs zsh
11.7 µs bash

C:
0.4 ns C

D'après les résultats:

Si vous voulez une boucle de shell un peu plus rapide, alors si vous avez la [[syntaxe et que vous voulez une boucle de shell rapide, vous êtes dans un shell avancé et vous avez aussi la boucle for de type C. Utilisez alors la boucle C comme pour. Ils peuvent être environ 2 fois plus rapides que les while [boucles dans la même coque.

  • ksh a la for (boucle la plus rapide à environ 2,7 µs par itération
  • le tiret a la while [boucle la plus rapide à environ 5,8 µs par itération

C pour les boucles peut être de 3 à 4 décimales plus rapide. (J'ai entendu les Torvalds adorer C).

La boucle C optimisée est 56500 fois plus rapide que la while [boucle de bash (la boucle de shell la plus lente) et 6750 fois plus rapide que la for (boucle de ksh (la boucle de shell la plus rapide).


Encore une fois, la lenteur des shells ne devrait pas avoir beaucoup d'importance, car le modèle typique des shells consiste à se décharger sur quelques processus de programmes externes optimisés.

Avec ce modèle, les shells facilitent souvent l'écriture de scripts avec des performances supérieures aux scripts python (la dernière fois que j'ai vérifié, créer des pipelines de processus en python était plutôt maladroit).

Une autre chose à considérer est le temps de démarrage.

time python3 -c ' '

prend 30 à 40 ms sur mon PC alors que les obus prennent environ 3 ms. Si vous lancez beaucoup de scripts, cela s'ajoute rapidement et vous pouvez faire beaucoup dans les 27 à 37 ms supplémentaires que python prend juste pour démarrer. Les petits scripts peuvent être terminés plusieurs fois au cours de cette période.

(NodeJs est probablement le pire runtime de script dans ce département car il faut environ 100 ms pour démarrer (même si une fois qu'il a démarré, vous auriez du mal à trouver un meilleur interprète parmi les langages de script)).

PSkocik
la source
Pour ksh, vous pouvez spécifier la mise en œuvre (AT & T ksh88, AT & T ksh93, pdksh, mksh...) comme il y a beaucoup de variations entre eux. Pour bash, vous souhaiterez peut-être spécifier la version. Il a fait quelques progrès ces derniers temps (cela s'applique également aux autres obus).
Stéphane Chazelas
@ StéphaneChazelas Merci. J'ai ajouté les versions des logiciels et du matériel utilisés.
PSkocik
Pour référence: créer un pipeline de processus en python que vous devez faire quelque chose comme: from subprocess import *; p1=Popen(['echo', 'something'], stdout=PIPE); p2 = Popen(['grep', 'pattern'], stdin=p1.stdout, stdout=PIPE); Popen(['wc', '-c'], stdin=PIPE). C'est en effet maladroit, mais il ne devrait pas être difficile de coder une pipelinefonction qui le fait pour vous pour un certain nombre de processus, ce qui entraîne pipeline(['echo', 'something'], ['grep', 'patter'], ['wc', '-c']).
Bakuriu
1
J'ai pensé que peut-être l'optimiseur gcc éliminait totalement la boucle. Ce n'est pas le cas, mais il fait toujours une optimisation intéressante: il utilise des instructions SIMD pour faire 4 ajouts en parallèle, réduisant le nombre d'itérations de boucle à 250000.
Mark Plotnick
1
@PSkocik: C'est juste à la limite de ce que les optimiseurs peuvent faire en 2016. Il semble que C ++ 17 obligera les compilateurs à calculer des expressions similaires au moment de la compilation (même pas comme une optimisation). Avec cette capacité C ++ en place, GCC peut également la prendre comme optimisation pour C.
MSalters
18

J'ai fait un peu de test, et sur mon système j'ai exécuté ce qui suit - aucun n'a accéléré l'ordre de grandeur qui serait nécessaire pour être compétitif, mais vous pouvez le faire plus rapidement:

Test 1: 18,233s

#!/bin/bash
i=0
while [[ $i -le 4000000 ]]
do
    let i++
done

test2: 20.45s

#!/bin/bash
i=0
while [[ $i -le 4000000 ]]
do 
    i=$(($i+1))
done

test3: 17,64 s

#!/bin/bash
i=0
while [[ $i -le 4000000 ]]; do let i++; done

test4: 26.69s

#!/bin/bash
i=0
while [ $i -le 4000000 ]; do let i++; done

test5: 12.79s

#!/bin/bash
export LC_ALL=C

for ((i=0; i != 4000000; i++)) { 
:
}

La partie importante dans ce dernier est l'export LC_ALL = C. J'ai constaté que de nombreuses opérations bash se terminent beaucoup plus rapidement si elles sont utilisées, en particulier toute fonction d'expression régulière. Il montre également une syntaxe non documentée pour utiliser le {} et le: comme no-op.

Erik Brandsberg
la source
3
+1 pour la suggestion LC_ALL, je ne le savais pas.
einpoklum - réintègre Monica
+1 Intéressant de voir comment le [[est tellement plus rapide que [. Je ne savais pas que LC_ALL = C (BTW vous n'avez pas besoin de l'exporter) a fait une différence.
PSkocik
@PSkocik Pour autant que je sache, [[est un bash intégré, et [est vraiment /bin/[, ce qui est le même que /bin/test- un programme externe. C'est pourquoi thay est plus lent.
tomsmeding
@tomsmending [est une fonction intégrée dans tous les shells courants (essayez type [). Le programme externe est pour la plupart inutilisé maintenant.
PSkocik
10

Un shell est efficace si vous l'utilisez pour ce pour quoi il a été conçu (bien que l'efficacité soit rarement ce que vous recherchez dans un shell).

Un shell est un interpréteur de ligne de commande, il est conçu pour exécuter des commandes et les faire coopérer à une tâche.

Si vous voulez compter jusqu'à 1000000000, vous invoquez un (une) commande à compter, comme seq, bc, awkou python/ perl... Exécution 1000000000 [[...]]commandes et 1000000000 letcommandes est lié à être terriblement inefficace, en particulier avec ce bashqui est le plus lent shell de tous.

À cet égard, un shell sera beaucoup plus rapide:

$ time sh -c 'seq 100000000' > /dev/null
sh -c 'seq 100000000' > /dev/null  0.77s user 0.03s system 99% cpu 0.805 total
$ time python -c 'i=0
> while i <= 100000000: i=i+1'
python -c 'i=0 while i <= 100000000: i=i+1'  12.12s user 0.00s system 99% cpu 12.127 total

Bien sûr, la plupart du travail est fait par les commandes que le shell appelle, comme il se doit.

Maintenant, vous pouvez bien sûr faire de même avec python:

python -c '
import os
os.dup2(os.open("/dev/null", os.O_WRONLY), 1);
os.execlp("seq", "seq", "100000000")'

Mais ce n'est pas vraiment comme ça que vous feriez les choses, pythoncar pythonc'est principalement un langage de programmation, pas un interpréteur de ligne de commande.

Notez que vous pourriez faire:

python -c 'import os; os.system("seq 100000000 > /dev/null")'

Mais, pythonserait en fait appeler un shell pour interpréter cette ligne de commande!

Stéphane Chazelas
la source
J'adore ta réponse. Tant d'autres réponses discutent des techniques améliorées du "comment", tandis que vous abordez à la fois le "pourquoi" et perceptivement le "pourquoi pas" en corrigeant l'erreur de méthodologie d'approche du PO.
greg.arnott
3

Rien n'est faux (sauf vos attentes) car python est vraiment assez rapide pour un langage non compilé, voir https://wiki.python.org/moin/PythonSpeed

Matija Nalis
la source
1
Je décourage plutôt les réponses comme celle-ci, cela appartient aux commentaires à mon humble avis.
LinuxSecurityFreak
2

Mis à part les commentaires, vous pouvez optimiser un peu le code , par exemple

#!/bin/bash
for (( i = 0; i <= 1000000000; i++ ))
do
: # null command
done

Ce code devrait prendre un peu moins de temps.

Mais évidemment pas assez rapide pour être réellement utilisable.

LinuxSecurityFreak
la source
-3

J'ai remarqué une différence dramatique dans bash par rapport à l'utilisation d'expressions logiques "while" et "until":

time (i=0 ; while ((i<900000)) ; do  i=$((i+1)) ; done )

real    0m5.339s
user    0m5.324s
sys 0m0.000s

time (i=0 ; until ((i=900000)) ; do  i=$((i+1)) ; done )

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

Non pas que cela soit vraiment extrêmement pertinent pour la question, à part que parfois de petites différences font une grande différence, même si nous nous attendons à ce qu'elles soient équivalentes.

pingouin intrépide
la source
6
Essayez avec celui-ci ((i==900000)).
Tomasz
2
Vous utilisez =pour l'affectation. Cela reviendra immédiatement. Aucune boucle n'aura lieu.
Wildcard
1
Avez-vous déjà utilisé Bash auparavant? :)
LinuxSecurityFreak