Perché l'iterazione su un file è due volte più veloce della lettura in memoria e dell'elaborazione due volte?


26

Sto confrontando quanto segue

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

con il seguente

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

e sorprendentemente il secondo impiega quasi 3 volte di più del primo. Dovrebbe essere più veloce, no?


Potrebbe essere perché la seconda soluzione, il contenuto del file viene letto 3 volte e solo due volte nel primo esempio?
Laurent C.

4
Almeno nel secondo esempio, il tuo non$( command substitution ) è in streaming. Tutto il resto avviene simultaneamente attraverso i tubi, ma nel secondo esempio devi attendere il log=completamento. Provalo con << QUI \ n $ {log = $ (comando)} \ n QUI - guarda cosa ottieni.
Mikeserv,

Nel caso di file di dimensioni estremamente grandi, macchine con memoria limitata o più elementi grepper cui, potresti vedere un po 'di speedup da usare, teequindi il file viene sicuramente letto solo una volta. cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
Matt

@LaurentC., No, viene letto solo una volta nel secondo esempio. C'è solo una chiamata alla coda.
psusi

Ora confronta questi con tail -n 10000 | fgrep -c '"success": true'e false.
Kojiro,

Risposte:


11

Da un lato, il primo metodo chiama taildue volte, quindi deve fare più lavoro del secondo metodo che lo fa solo una volta. D'altra parte, il secondo metodo deve copiare i dati nella shell e quindi tornare indietro, quindi deve fare più lavoro rispetto alla prima versione in cui tailviene direttamente convogliato grep. Il primo metodo ha un vantaggio in più su una macchina multi-processore: greppuò funzionare in parallelo con tail, mentre il secondo metodo è rigorosamente serializzato, prima tail, poi grep.

Quindi non c'è una ragione ovvia per cui uno dovrebbe essere più veloce dell'altro.

Se vuoi vedere cosa sta succedendo, guarda come il sistema chiama la shell. Prova anche con diverse shell.

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'

Con il metodo 1, le fasi principali sono:

  1. tail legge e cerca di trovare il suo punto di partenza.
  2. tailscrive blocchi di 4096 byte che grepleggono velocemente quanto vengono prodotti.
  3. Ripetere il passaggio precedente per la seconda stringa di ricerca.

Con il metodo 2, le fasi principali sono:

  1. tail legge e cerca di trovare il suo punto di partenza.
  2. tail scrive blocchi di 4096 byte che bash legge 128 byte alla volta e zsh legge 4096 byte alla volta.
  3. Bash o zsh scrive blocchi da 4096 byte che grepleggono velocemente quanto vengono prodotti.
  4. Ripetere il passaggio precedente per la seconda stringa di ricerca.

I blocchi di 128 byte di Bash durante la lettura dell'output della sostituzione del comando lo rallentano significativamente; zsh esce velocemente quanto il metodo 1 per me. Il chilometraggio può variare in base al tipo e al numero di CPU, alla configurazione dello scheduler, alle versioni degli strumenti coinvolti e alla dimensione dei dati.


La dimensione della pagina della figura 4k dipende? Voglio dire, tail e zsh sono entrambi solo syscalls mmaping? (Forse è una terminologia errata, anche se spero di no ...) Cosa sta facendo Bash in modo diverso?
Mikeserv,

Questo è il posto giusto per Gilles! Con zsh il secondo metodo è leggermente più veloce sulla mia macchina.
phunehehe,

Ottimo lavoro Gilles, tks.
X Tian

@mikeserv Non ho guardato la fonte per vedere come questi programmi scelgono la dimensione. Le ragioni più probabili per vedere 4096 sarebbero una costante incorporata o il st_blksizevalore di una pipe, che è 4096 su questa macchina (e non so se è perché è la dimensione della pagina MMU). I 128 di Bash dovrebbero essere una costante incorporata.
Gilles 'SO-smetti di essere malvagio' il

@Gilles, grazie per la risposta ponderata. Ultimamente sono stato curioso di conoscere le dimensioni della pagina.
Mikeserv,

26

Ho fatto il seguente test e sul mio sistema la differenza risultante è circa 100 volte più lunga per il secondo script.

Il mio file è un output di strace chiamato bigfile

$ wc -l bigfile.log 
1617000 bigfile.log

Script

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

In realtà non ho alcuna corrispondenza per il grep, quindi nulla viene scritto nell'ultima pipe fino a wc -l

Ecco i tempi:

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

Quindi ho eseguito nuovamente i due script tramite il comando strace

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

Ecco i risultati delle tracce:

$ 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

Analisi

Non sorprende che, in entrambi i casi, la maggior parte del tempo passi in attesa del completamento di un processo, ma p2 attende 2,63 volte più a lungo di p1 e, come altri hanno già detto, si inizia in ritardo in p2.sh.

Quindi ora dimentica il waitpid, ignora la %colonna e guarda la colonna dei secondi su entrambe le tracce.

Il tempo più grande p1 trascorre la maggior parte del suo tempo in lettura probabilmente comprensibile, perché c'è un file di grandi dimensioni da leggere, ma p2 impiega 28,82 volte in più in lettura rispetto a p1. - bashnon si aspetta di leggere un file così grande in una variabile e probabilmente legge il buffer alla volta, si divide in righe e ne ottiene un altro.

il numero di letture p2 è 705k contro 84k per p1, ciascuna lettura richiede un cambio di contesto nello spazio del kernel ed esce di nuovo. Quasi 10 volte il numero di letture e cambi di contesto.

Il tempo in scrittura p2 trascorre 41,93 volte più a lungo in scrittura di p1

il conteggio delle scritture p1 fa più scritture di p2, 42k contro 21k, tuttavia sono molto più veloci.

Probabilmente a causa delle echorighe in greprispetto ai buffer di scrittura della coda.

Inoltre , p2 trascorre più tempo in scrittura che in lettura, p1 è il contrario!

Altro fattore Guarda il numero di brkchiamate di sistema: p2 spende 2.42 volte più a lungo di quanto non legga! In p1 (non si registra nemmeno). brkè quando il programma deve espandere il suo spazio di indirizzi perché inizialmente non è stato allocato abbastanza, ciò è probabilmente dovuto al fatto che bash deve leggere quel file nella variabile e non aspettarsi che sia così grande, e come ha detto @scai, se il il file diventa troppo grande, anche quello non funzionerebbe.

tailè probabilmente un lettore di file abbastanza efficiente, poiché è quello che è stato progettato per fare, probabilmente memorizza il file e scansiona le interruzioni di linea, permettendo così al kernel di ottimizzare l'I / O. bash non è altrettanto buono sia nel tempo trascorso a leggere che a scrivere.

p2 impiega 44ms e 41ms in clonee execvnon è un valore misurabile per p1. Probabilmente bash leggendo e creando la variabile dalla coda.

Finalmente il totale p1 esegue ~ 150k chiamate di sistema contro p2 740k (4.93 volte maggiore).

Eliminando waitpid, p1 impiega 0,014416 secondi per eseguire chiamate di sistema, p2 0,439132 secondi (30 volte più a lungo).

Quindi sembra che p2 passi la maggior parte del tempo nello spazio utente senza fare altro che aspettare che le chiamate di sistema vengano completate e che il kernel riorganizzi la memoria, p1 esegue più scritture, ma è più efficiente e causa un carico di sistema significativamente inferiore, quindi è più veloce.

Conclusione

Non proverei mai a preoccuparmi di scrivere codice attraverso la memoria quando scrivo uno script bash, ciò non significa che non cerchi di essere efficiente.

tailè progettato per fare ciò che fa, probabilmente memory mapsil file in modo che sia efficiente da leggere e consente al kernel di ottimizzare l'I / O.

Un modo migliore per ottimizzare il tuo problema potrebbe essere innanzitutto quello grepdi "successo": "linee e poi contare le verità e i falsi, grepha un'opzione di conteggio che evita ancora wc -l, o ancora meglio, awkconvogliare la coda e contare le verità e falsi contemporaneamente. p2 non solo richiede molto tempo, ma aggiunge carico al sistema mentre la memoria viene mescolata con i brks.


2
TL; DR: malloc (); se potessi dire a $ log quanto deve essere grande e scriverlo rapidamente in una sola operazione senza riallocazioni, probabilmente sarebbe altrettanto veloce.
Chris K,

5

In realtà anche la prima soluzione legge il file in memoria! Questo è chiamato cache e viene automaticamente eseguito dal sistema operativo.

E come già correttamente spiegato da mikeserv, la prima soluzione viene eseguita grep mentre il file viene letto mentre la seconda soluzione lo esegue dopo che il file è stato letto tail.

Quindi la prima soluzione è più veloce a causa di varie ottimizzazioni. Ma questo non deve sempre essere vero. Per file molto grandi che il sistema operativo decide di non memorizzare nella cache, la seconda soluzione potrebbe diventare più veloce. Ma nota che per file ancora più grandi che non si adattano alla tua memoria la seconda soluzione non funzionerà affatto.


3

Penso che la differenza principale sia semplicemente echolenta. Considera questo:

$ 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

Come puoi vedere sopra, il passaggio che richiede tempo è la stampa dei dati. Se reindirizzi semplicemente a un nuovo file e lo esegui, è molto più veloce quando si legge il file solo una volta.


E come richiesto, con una stringa qui:

 $ 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

Questo è ancora più lento, presumibilmente perché la stringa qui sta concatenando tutti i dati su una linea lunga e questo rallenterà 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 la variabile viene quotata in modo che non si verifichi alcuna divisione, le cose sono un po 'più veloci:

 $ 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

Ma è ancora lento perché la fase di limitazione della velocità sta stampando i dati.


Perché non provi <<<sarebbe interessante vedere se questo fa la differenza.
Graeme,

3

Ho provato anche questo ... Innanzitutto, ho creato il file:

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

Se esegui quanto sopra da solo, dovresti trovare 1,5 milioni di linee /tmp/logcon un rapporto 2: 1 tra "success": "true"linee e "success": "false"linee.

La prossima cosa che ho fatto è stato eseguire alcuni test. Ho eseguito tutti i test attraverso un proxy, shquindi timeavrei dovuto solo guardare un singolo processo e quindi avrei potuto mostrare un singolo risultato per l'intero lavoro.

Questo sembra essere il più veloce, anche se aggiunge un secondo descrittore di file e tee,anche se penso di poter spiegare perché:

    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

Ecco il tuo primo:

    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 il tuo secondo:

    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

Puoi vedere che nei miei test c'era più di una differenza di velocità di 3 * quando la leggevi in ​​una variabile come hai fatto tu.

Penso che parte di ciò sia che una variabile di shell deve essere divisa e gestita dalla shell quando viene letta - non è un file.

A here-documentd'altra parte, a tutti gli effetti, è a file- afile descriptor, comunque. E come tutti sappiamo - Unix funziona con i file.

La cosa più interessante per me here-docsè che puoi manipolarne file-descriptors- come una scala |pipe- ed eseguirle. Questo è molto utile in quanto ti consente un po 'più di libertà nel puntare |pipedove vuoi.

Ho dovuto teeil tailperché le prime grepmangia le here-doc |pipee di lì niente per il secondo da leggere. Ma dal momento che |pipedin /dev/fd/3e presi di nuovo a passare ad >&1 stdout,esso non ha molta importanza. Se usi grep -cmolti altri raccomandano:

    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

È ancora più veloce.

Ma quando lo eseguo senza . sourcingil heredocnon riesco a mettere in background con successo il primo processo per eseguirli completamente contemporaneamente. Qui è senza sfondo 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

Ma quando aggiungo il &:

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

Tuttavia, la differenza sembra essere solo di pochi centesimi di secondo, almeno per me, quindi prendila come vuoi.

Ad ogni modo, il motivo per cui viene eseguito più velocemente teeè perché entrambi vengono grepseseguiti contemporaneamente con una sola chiamata di tail. teeduplicati del file per noi e lo dividono nel secondo grepprocesso tutto in-stream: tutto viene eseguito contemporaneamente dall'inizio alla fine, quindi finiscono tutti allo stesso tempo.

Quindi tornando al tuo primo esempio:

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

E il tuo secondo:

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

Ma quando dividiamo i nostri input ed eseguiamo i nostri processi contemporaneamente:

          3>&1  | grep #now we're done
              /        
    tail | tee  #both process together
              \  
          >&1   | grep #now we're done

1
+1 ma il tuo ultimo test è morto con un errore di sintassi, non credo che i tempi siano corretti lì :)
terdon

@terdon Potrebbero sbagliarsi - stavo sottolineando che è morto. Ho mostrato la differenza tra & e no & - quando lo aggiungi, la shell si arrabbia. Ma ho fatto un sacco di copia / incolla, quindi avrei potuto incasinare uno o due, ma penso che stiano bene ...
Mikeserv,

sh: riga 2: errore di sintassi vicino al token imprevisto `| '
terdon

@terdon Yeah that - "Non riesco a mettere in background con successo il primo processo per eseguirli in concomitanza. Vedi?" Il primo non è in background, ma quando aggiungo e nel tentativo di farlo "token inaspettato". Quando io . fonte l'eredità che posso usare il &.
Mikeserv,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.