Existe algo errado com o meu script ou o Bash é muito mais lento que o Python?

29

Eu estava testando a velocidade do Bash e Python executando um loop 1 bilhão de vezes.

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

Código Bash:

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

Usando o timecomando, descobri que o código Python leva apenas 48 segundos para terminar, enquanto o código Bash demorava mais de 1 hora antes de eu matar o script.

Porque isto é assim? Eu esperava que o Bash fosse mais rápido. Existe algo errado com meu script ou o Bash é realmente muito mais lento com esse script?

Edward Torvalds
fonte
49
Não sei ao certo por que você esperava que o Bash fosse mais rápido que o Python.
Kusalananda
9
@MatijaNalis não, você não pode! O script é carregado na memória. A edição do arquivo de texto do qual foi lido (o arquivo de script) não terá absolutamente nenhum efeito no script em execução. Uma coisa boa também, o bash já é lento o suficiente sem precisar abrir e reler um arquivo toda vez que um loop é executado!
terdon
4
O Bash lê o arquivo linha por linha enquanto é executado, mas lembra o que leu se voltar a essa linha (porque está em um loop ou em uma função). A alegação original sobre reler cada iteração não é verdadeira, mas as modificações nas linhas ainda a serem alcançadas serão efetivas. Uma demonstração interessante: crie um arquivo contendo echo echo hello >> $0e execute-o.
22716 Michael Homer
3
@MatijaNalis ah, OK, eu posso entender isso. Foi a ideia de mudar um ciclo de corrida que me impressionou. Presumivelmente, cada linha é lida sequencialmente e somente após o término da última. No entanto, um loop é tratado como um único comando e será lido na íntegra, portanto, alterá-lo não afetará o processo em execução. Porém, uma distinção interessante: eu sempre assumi que o script inteiro é carregado na memória antes da execução. Obrigado por apontar isso!
terdon

Respostas:

17

Este é um bug conhecido no bash; veja a página de manual e procure por "BUGS":

BUGS
       It's too big and too slow.

;)


Para uma excelente cartilha sobre as diferenças conceituais entre scripts de shell e outras linguagens de programação, recomendo a leitura:

Os trechos mais pertinentes:

Os reservatórios são uma linguagem de nível superior. Pode-se dizer que nem é uma língua. Eles estão antes de todos os intérpretes de linha de comando. O trabalho é realizado por esses comandos que você executa e o shell serve apenas para orquestrá-los.

...

IOW, em shells, especialmente para processar texto, você invoca o menor número possível de utilitários e os coopera com a tarefa, não executa milhares de ferramentas em sequência, esperando que cada um inicie, execute, limpe antes de executar o próximo.

...

Como dito anteriormente, executar um comando tem um custo. Um custo enorme se esse comando não estiver embutido, mas mesmo se estiverem embutidos, o custo será alto.

E os shells não foram projetados para funcionar assim, não têm pretensão de serem linguagens de programação com desempenho. Eles não são, são apenas intérpretes de linha de comando. Portanto, pouca otimização foi feita nessa frente.


Não use loops grandes em scripts de shell.

Curinga
fonte
54

Os loops de shell são lentos e os do bash são os mais lentos. Os reservatórios não devem fazer trabalho pesado em loops. Os shells destinam-se a iniciar alguns processos externos otimizados em lotes de dados.


Enfim, fiquei curioso para comparar os loops de shell, então fiz um pequeno benchmark:

#!/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

( Detalhes:

  • Processador: CPU Intel (R) Core (i) i5 M 430 a 2,27 GHz
  • ksh: versão sh (AT&T Research) 93u + 01/08/2012
  • bash: GNU bash, versão 4.3.11 (1) -release (x86_64-pc-linux-gnu)
  • zsh: zsh 5.2 (x86_64-unknown-linux-gnu)
  • traço: 0.5.7-4ubuntu1

)

Os resultados (abreviados) (tempo por iteração) são:

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

Dos resultados:

Se você deseja um loop de shell um pouco mais rápido, se possui a [[sintaxe e deseja um loop de shell rápido, está em um shell avançado e também possui o loop for tipo C. Use C como for loop, então. Eles podem ser cerca de 2 vezes mais rápidos que os while [loops no mesmo shell.

  • O ksh tem o for (loop mais rápido em cerca de 2,7 µs por iteração
  • o traço tem o while [loop mais rápido, com cerca de 5,8 µs por iteração

C para loops pode ser de 3 a 4 ordens decimais de magnitude mais rápido. (Eu ouvi os Torvalds amarem C).

O loop C for otimizado é 56500 vezes mais rápido que o while [loop do bash (o loop de shell mais lento) e 6750 vezes mais rápido que o for (loop de ksh (o loop de shell mais rápido).


Novamente, a lentidão dos shells não deve importar muito, porque o padrão típico dos shells é descarregar para alguns processos de programas externos otimizados.

Com esse padrão, os shells geralmente tornam muito mais fácil escrever scripts com desempenho superior aos scripts python (da última vez que verifiquei, a criação de pipelines de processos em python era bastante desajeitada).

Outra coisa a considerar é o tempo de inicialização.

time python3 -c ' '

leva de 30 a 40 ms no meu PC, enquanto as conchas demoram cerca de 3ms. Se você lança muitos scripts, isso se soma rapidamente e você pode fazer muito nos 27 a 37 ms extras que o python leva apenas para iniciar. Pequenos scripts podem ser concluídos várias vezes nesse período.

(O NodeJs é provavelmente o pior tempo de execução de script nesse departamento, pois leva cerca de 100 ms apenas para iniciar (mesmo que, uma vez iniciado, seja difícil encontrar um melhor desempenho entre as linguagens de script)).

PSkocik
fonte
Para ksh, você pode querer especificar a implementação (AT & T ksh88, a AT & T ksh93, pdksh, mksh...) como não há um monte de variação entre eles. Para bash, você pode querer especificar a versão. Ultimamente, houve algum progresso (que também se aplica a outras conchas).
Stéphane Chazelas 13/08/16
@ StéphaneChazelas Obrigado. Eu adicionei as versões do software e hardware usados.
PSKocik
Para referência: para criar um pipeline de processo em python que você tem que fazer algo como: from subprocess import *; p1=Popen(['echo', 'something'], stdout=PIPE); p2 = Popen(['grep', 'pattern'], stdin=p1.stdout, stdout=PIPE); Popen(['wc', '-c'], stdin=PIPE). Isso é realmente desajeitado, mas não deve ser difícil codificar uma pipelinefunção que faz isso por você para qualquer número de processos, resultando em pipeline(['echo', 'something'], ['grep', 'patter'], ['wc', '-c']).
Bakuriu 13/08/16
1
Eu pensei que talvez o otimizador gcc estivesse eliminando totalmente o loop. Não é, mas ele ainda está fazendo uma otimização interessante: ele usa instruções SIMD para fazer 4 acrescenta em paralelo, reduzindo o número de iterações do loop para 250000.
Mark Plotnick
1
@PSkocik: É exatamente o que os otimizadores podem fazer em 2016. Parece que o C ++ 17 exigirá que os compiladores sejam capazes de calcular expressões semelhantes em tempo de compilação (nem mesmo como uma otimização). Com esse recurso de C ++, o GCC também pode buscá-lo como uma otimização para C.
MSalters
18

Fiz alguns testes e, no meu sistema, executei o seguinte - nenhum fez a aceleração da ordem de magnitude necessária para ser competitiva, mas você pode torná-lo mais rápido:

Teste 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.64s

#!/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++)) { 
:
}

A parte importante deste último é a exportação LC_ALL = C. Descobri que muitas operações do bash terminam significativamente mais rápidas se forem usadas, em particular qualquer função de expressão regular. Ele também mostra uma sintaxe não documentada para usar o {} e o: como um não operacional.

Erik Brandsberg
fonte
3
+1 para a sugestão LC_ALL, eu não sabia disso.
einpoklum - reinstala Monica 13/08
+1 Interessante como [[é muito mais rápido que [. Eu não sabia que LC_ALL = C (BTW, você não precisa exportá-lo) fez a diferença.
PSKocik
@PSkocik Até onde eu sei, [[é um bash embutido e [realmente /bin/[é o mesmo que /bin/test- um programa externo. Por isso, é mais lento.
tomsmeding
O @tomsmending [está integrado em todos os shells comuns (tente type [). O programa externo não está mais em uso no momento.
PSKocik
10

Um shell é eficiente se você o usar para o que foi projetado (embora a eficiência raramente seja o que você procura em um shell).

Um shell é um intérprete de linha de comando, projetado para executar comandos e fazer com que eles cooperem com uma tarefa.

Se você quiser contar até 1000000000, você invocar um (um) comando para contar, como seq, bc, awkou python/ perl... Correndo 1000000000 [[...]]comandos e 1000000000 letcomandos é obrigado a ser terrivelmente ineficiente, especialmente com basho que é a casca mais lento de todos.

Nesse sentido, um shell será muito mais rápido:

$ 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

Embora, é claro, a maior parte do trabalho seja realizada pelos comandos que o shell chama, como deveria ser.

Agora, é claro que você pode fazer o mesmo com python:

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

Mas não é exatamente assim que você faria as coisas, pythonpois pythoné principalmente uma linguagem de programação, não um interpretador de linha de comando.

Observe que você pode fazer:

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

Mas, pythonna verdade, estaria chamando um shell para interpretar essa linha de comando!

Stéphane Chazelas
fonte
Eu amo sua resposta. Tantas outras respostas discutem técnicas aprimoradas de "como", enquanto você aborda o "porquê" e perceptivamente o "por que não" abordando o erro na metodologia de abordagem do OP.
greg.arnott
3

Nada está errado (exceto suas expectativas), já que o python é realmente bastante rápido para linguagem não compilada, consulte https://wiki.python.org/moin/PythonSpeed

Matija Nalis
fonte
1
Eu prefiro desencorajar a partir de respostas como esta, isso pertence aos comentários IMHO.
LinuxSecurityFreak 22/16/16
2

Além dos comentários, você pode otimizar um pouco o código , por exemplo,

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

Esse código deve demorar um pouco menos.

Mas obviamente não é rápido o suficiente para ser realmente utilizável.

LinuxSecurityFreak
fonte
-3

Percebi uma diferença dramática no bash do uso de expressões "while" e "till" logicamente equivalentes:

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

Não que ele realmente tenha uma tremenda relevância para a questão, exceto que, às vezes, pequenas diferenças fazem uma grande diferença, mesmo que esperássemos que elas fossem equivalentes.

pinguim intrépido
fonte
6
Tente com este ((i==900000)).
Tomasz
2
Você está usando =para atribuição. Ele retornará verdadeiro imediatamente. Nenhum loop ocorrerá.
Curinga
1
Você já usou o Bash antes? :)
LinuxSecurityFreak