Por que iterar sobre um arquivo duas vezes mais rápido do que lê-lo na memória e computar duas vezes?

26

Estou comparando o seguinte

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

com o seguinte

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

e surpreendentemente o segundo demora quase três vezes mais que o primeiro. Deveria ser mais rápido, não deveria?

phunehehe
fonte
Poderia ser porque na segunda solução, o conteúdo do arquivo é lido 3 vezes e apenas duas vezes no primeiro exemplo?
19414 Laurent C.
4
Pelo menos no segundo exemplo, o seu não$( command substitution ) é transmitido. Todo o resto acontece através de pipes simultaneamente, mas no segundo exemplo, você precisa aguardar a conclusão do processo. Experimente com << HERE \ n $ {log = $ (command)} \ nHERE - veja o que você recebe. log=
mikeserv
No caso de arquivos extremamente grandes, máquinas com restrição de memória ou mais itens grep, você poderá ver alguma aceleração usando, teepara que o arquivo seja definitivamente lido apenas uma vez. cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
19414 Matt
@LaurentC., Não, ele é lido apenas uma vez no segundo exemplo. Há apenas uma chamada para cauda.
Psp #
Agora compare-os com tail -n 10000 | fgrep -c '"success": true'e false.
Kojiro

Respostas:

11

Por um lado, o primeiro método chama tailduas vezes, portanto, ele tem que fazer mais trabalho do que o segundo método, que só faz isso uma vez. Por outro lado, o segundo método precisa copiar os dados para o shell e, em seguida, voltar atrás, portanto, ele tem que fazer mais trabalho do que a primeira versão na qual tailé diretamente canalizado grep. O primeiro método possui uma vantagem extra em uma máquina com vários processadores: greppode trabalhar em paralelo com tail, enquanto o segundo método é estritamente serializado, primeiro taile depois grep.

Portanto, não há razão óbvia para que um seja mais rápido que o outro.

Se você quiser ver o que está acontecendo, veja como o sistema chama o shell. Tente com conchas diferentes também.

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'

Com o método 1, os principais estágios são:

  1. tail lê e procura encontrar o seu ponto de partida.
  2. tailescreve pedaços de 4096 bytes que greplê tão rápido quanto são produzidos.
  3. Repita a etapa anterior para a segunda sequência de pesquisa.

Com o método 2, os principais estágios são:

  1. tail lê e procura encontrar o seu ponto de partida.
  2. tail grava pedaços de 4096 bytes que o bash lê 128 bytes por vez e o zsh lê 4096 bytes por vez.
  3. O Bash ou o zsh grava pedaços de 4096 bytes que são greplidos tão rapidamente quanto são produzidos.
  4. Repita a etapa anterior para a segunda sequência de pesquisa.

Os blocos de 128 bytes do Bash ao ler a saída da substituição de comando diminuem significativamente; O zsh é tão rápido quanto o método 1 para mim. Sua milhagem pode variar dependendo do tipo e número da CPU, configuração do planejador, versões das ferramentas envolvidas e tamanho dos dados.

Gilles 'SO- parar de ser mau'
fonte
O tamanho da página de 4k figuras depende? Quero dizer, tail e zsh são apenas mmaping syscalls? (Possivelmente, essa terminologia está incorreta, embora eu espero que não ...) O que o bash está fazendo de diferente?
mikeserv
Este é o local em Gilles! Com o zsh, o segundo método é um pouco mais rápido na minha máquina.
phunehehe
Bom trabalho Gilles, tks.
X Tian
@ MikeServ Eu não olhei para a fonte para ver como esses programas escolhem o tamanho. Os motivos mais prováveis ​​para ver 4096 seriam uma constante interna ou o st_blksizevalor de um canal, que é 4096 nesta máquina (e não sei se é porque é o tamanho da página da MMU). O 128 de Bash teria que ser uma constante embutida.
Gilles 'SO- stop being evil
@Gilles, obrigado pela resposta atenciosa. Ultimamente, tenho estado curioso sobre os tamanhos das páginas.
mikeserv
26

Eu fiz o teste a seguir e no meu sistema a diferença resultante é cerca de 100 vezes mais longa para o segundo script.

Meu arquivo é uma saída strace chamada 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

Na verdade, não tenho correspondências para o grep, então nada é gravado no último canal até wc -l

Aqui estão os horários:

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

Então, eu executei os dois scripts novamente através do comando strace

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

Aqui estão os resultados dos rastreamentos:

$ 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

E 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

Análise

Não é de surpreender que, em ambos os casos, a maior parte do tempo seja gasta aguardando a conclusão de um processo, mas p2 espera 2,63 vezes mais que p1 e, como outros já mencionaram, você está começando tarde no p2.sh.

Então agora esqueça o waitpid, ignore a %coluna e observe a coluna dos segundos nos dois rastreamentos.

O maior tempo em que p1 passa a maior parte do tempo na leitura provavelmente é compreensível, porque há um arquivo grande para ler, mas o p2 gasta 28,82 vezes mais em leitura do que o p1. - bashnão espera ler um arquivo tão grande em uma variável e provavelmente está lendo um buffer de cada vez, dividindo-se em linhas e, em seguida, obtendo outro.

a contagem de leituras p2 é 705k vs 84k para a p1, cada leitura exigindo uma alternância de contexto para o espaço do kernel e saindo novamente. Quase 10 vezes o número de leituras e alternâncias de contexto.

O tempo na gravação p2 passa 41,93 vezes mais na gravação do que na p1

o número de gravações p1 faz mais gravações que p2, 42k vs 21k, porém são muito mais rápidos.

Provavelmente por causa das echolinhas em grepoposição aos buffers de escrita de cauda.

Além disso , p2 gasta mais tempo na gravação do que na leitura, p1 é o contrário!

Outro fator Veja o número de brkchamadas do sistema: o p2 gasta 2,42 vezes mais tempo do que a leitura! Na p1 (ele nem se registra). brké quando o programa precisa expandir seu espaço de endereço porque o suficiente não foi alocado inicialmente, isso provavelmente se deve ao bash ter que ler esse arquivo na variável e não esperar que ele seja tão grande e, como o @scai mencionou, se o o arquivo fica muito grande, mesmo isso não funcionaria.

tailé provavelmente um leitor de arquivos bastante eficiente, porque é para isso que ele foi projetado para fazer, provavelmente cria um mapa do arquivo e procura por quebras de linha, permitindo que o kernel otimize a E / S. bash não é tão bom, tanto no tempo gasto lendo e escrevendo.

O p2 gasta 44ms e 41ms clonee execvnão é um valor mensurável para o p1. Provavelmente bash lendo e criando a variável a partir da cauda.

Finalmente, o total p1 executa ~ 150k chamadas do sistema vs p2 740k (4,93 vezes maior).

Eliminando waitpid, p1 gasta 0,014416 segundos na execução de chamadas do sistema, p2 0,439132 segundos (30 vezes mais).

Portanto, parece que o p2 passa a maior parte do tempo no espaço do usuário sem fazer nada, exceto aguardando a conclusão das chamadas do sistema e o kernel para reorganizar a memória, o p1 realiza mais gravações, mas é mais eficiente e causa uma carga do sistema significativamente menor e, portanto, é mais rápido.

Conclusão

Eu nunca tentaria me preocupar com a codificação através da memória ao escrever um script bash, isso não significa dizer que você não tenta ser eficiente.

tailfoi projetado para fazer o que faz, provavelmente memory mapso arquivo para que seja eficiente para ler e permita que o kernel otimize a E / S.

Uma maneira melhor de otimizar seu problema pode ser o primeiro greppara '' sucesso '': as linhas e depois contar as verdades e falsidades, greptem uma opção de contagem que novamente evita wc -l, ou melhor ainda, canaliza a cauda awke conta verdades e falsifica simultaneamente. O p2 não apenas demora, mas adiciona carga ao sistema enquanto a memória está sendo embaralhada com brks.

X Tian
fonte
2
TL; DR: malloc (); se você pudesse dizer ao $ log qual o tamanho necessário e escrever rapidamente em uma operação sem realocações, provavelmente seria o mais rápido possível.
Chris K
5

Na verdade, a primeira solução também lê o arquivo na memória! Isso é chamado de armazenamento em cache e é feito automaticamente pelo sistema operacional.

E, como já explicado corretamente pelo mikeserv, a primeira solução é executada grep enquanto o arquivo está sendo lido, enquanto a segunda solução a executa após a leitura do arquivo tail.

Portanto, a primeira solução é mais rápida devido a várias otimizações. Mas isso nem sempre precisa ser verdade. Para arquivos realmente grandes que o sistema operacional decide não armazenar em cache, a segunda solução pode se tornar mais rápida. Mas observe que, para arquivos ainda maiores, que não cabem na memória, a segunda solução não funciona.

scai
fonte
3

Eu acho que a principal diferença é muito simples echoe lenta. Considere isto:

$ 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

Como você pode ver acima, a etapa demorada é imprimir os dados. Se você simplesmente redirecionar para um novo arquivo e grep, é muito mais rápido ao ler o arquivo apenas uma vez.


E conforme solicitado, com uma string here:

 $ 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

Essa é ainda mais lenta, presumivelmente porque a string here está concatenando todos os dados em uma linha longa e isso reduzirá a velocidade 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

Se a variável for citada para que não ocorra divisão, as coisas serão um pouco mais rápidas:

 $ 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

Mas ainda é lento porque a etapa de limitação da taxa está imprimindo os dados.

terdon
fonte
Por que você não tenta <<<, seria interessante ver se isso faz diferença?
Graeme
3

Eu tentei isso também ... Primeiro, criei o arquivo:

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

Se você executar o procedimento acima, deverá criar 1,5 milhões de linhas /tmp/logcom uma proporção de 2: 1 de "success": "true"linhas para "success": "false"linhas.

A próxima coisa que fiz foi executar alguns testes. Eu executei todos os testes por meio de um proxy, shportanto, timeseria necessário apenas assistir a um único processo - e, portanto, poderia mostrar um único resultado para todo o trabalho.

Este parece ser o mais rápido, apesar de adicionar um segundo descritor de arquivo e tee,embora eu possa explicar por que:

    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

Aqui está o seu primeiro:

    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

E o seu segundo:

    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

Você pode ver que, em meus testes, houve mais de uma diferença de velocidade de 3 * ao lê-la em uma variável, como você fez.

Eu acho que parte disso é que uma variável do shell precisa ser dividida e manipulada pelo shell quando está sendo lida - não é um arquivo.

A, here-documentpor outro lado, para todos os efeitos, é um file- defile descriptor, qualquer maneira. E como todos sabemos - o Unix trabalha com arquivos.

O que é mais interessante para mim here-docsé que você pode manipulá-los file-descriptors- como um straight |pipe- e executá-los. Isso é muito útil, pois permite um pouco mais de liberdade em apontar para |pipeonde você deseja.

Eu tinha teedo tailporque os primeiros grepcome as here-doc |pipee está lá nada para a segunda leitura. Mas desde que eu |pipedlo em /dev/fd/3e pegou de novo para passar para >&1 stdout,ele não importava muito. Se você usar grep -ctantas outras, recomende:

    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

É ainda mais rápido.

Mas quando eu o executo sem . sourcingo heredocêxito, não consigo obter o segundo plano com êxito do primeiro processo para executá-los de forma totalmente simultânea. Aqui está, sem fundo completo:

    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

Mas quando eu adiciono o &:

    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 `|'

Ainda assim, a diferença parece ser de apenas alguns centésimos de segundo, pelo menos para mim, então aceite como quiser.

De qualquer forma, o motivo pelo qual ele é executado mais rapidamente teeé porque ambos são grepsexecutados ao mesmo tempo com apenas uma chamada de tail. teeduplicatas do arquivo para nós e o dividem para o segundo grepprocesso, todos in-stream - tudo é executado ao mesmo tempo do começo ao fim. todos terminam na mesma hora também.

Então, voltando ao seu primeiro exemplo:

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

E o seu segundo:

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

Mas quando dividimos nossa entrada e executamos nossos processos simultaneamente:

          3>&1  | grep #now we're done
              /        
    tail | tee  #both process together
              \  
          >&1   | grep #now we're done
mikeserv
fonte
1
+1, mas seu último teste morreu com um erro de sintaxe, não acho que os horários estejam corretos :) :)
terdon
@terdon Eles podem estar errados - eu estava apontando que ele morreu. Eu mostrei a diferença entre o & e no & - quando você o adiciona, o shell fica chateado. Mas eu fiz um monte de copiar / colar, então eu poderia ter desarrumada um ou dois, mas acho que está tudo bem ...
mikeserv
sh: linha 2: erro de sintaxe próximo ao token inesperado `| '
terdon
@terdon Sim, isso - "Não consigo obter o segundo plano com êxito do primeiro processo para executá-los de forma totalmente simultânea. Está vendo?" O primeiro não está em segundo plano, mas quando adiciono & na tentativa de fazê-lo "token inesperado". Quando eu . fonte do heredoc eu posso usar o &.
mikeserv