Mengapa iterasi file dua kali lebih cepat daripada membacanya ke dalam memori dan komputasi dua kali?

26

Saya membandingkan yang berikut ini

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

dengan berikut ini

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

dan mengejutkan yang kedua memakan waktu hampir 3 kali lebih lama dari yang pertama. Seharusnya lebih cepat, bukan?

phhehehe
sumber
Mungkinkah karena solusi kedua, konten file dibaca 3 kali, dan hanya dua kali dalam contoh pertama?
Laurent C.
4
Setidaknya dalam contoh kedua, Anda $( command substitution )yaitu tidak mengalir. Semua sisanya terjadi melalui pipa secara bersamaan, tetapi dalam contoh kedua Anda harus menunggu log=sampai selesai. Cobalah dengan << DI SINI \ n $ {log = $ (command)} \ nHERE - lihat apa yang Anda dapatkan.
mikeserv
Dalam hal file yang sangat besar, mesin yang dibatasi memori, atau lebih banyak item grep, Anda mungkin melihat beberapa teepeningkatan kecepatan menggunakan sehingga file tersebut pasti hanya dibaca sekali. cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
Matt
@LaurentC., Tidak, ini hanya dibaca satu kali pada contoh kedua. Hanya ada satu panggilan untuk membuntuti.
psusi
Sekarang bandingkan ini dengan tail -n 10000 | fgrep -c '"success": true'false.
kojiro

Jawaban:

11

Di satu sisi, metode pertama memanggil taildua kali, sehingga harus melakukan lebih banyak pekerjaan daripada metode kedua yang hanya melakukan ini sekali. Di sisi lain, metode kedua harus menyalin data ke dalam shell dan kemudian mundur, sehingga harus melakukan lebih banyak pekerjaan daripada versi pertama di mana taillangsung disalurkan ke grep. Metode pertama memiliki keunggulan ekstra pada mesin multi-prosesor: grepdapat bekerja secara paralel dengan tail, sedangkan metode kedua adalah serial, pertama tail, kemudian grep.

Jadi tidak ada alasan yang jelas mengapa yang satu harus lebih cepat dari yang lain.

Jika Anda ingin melihat apa yang terjadi, lihat sistem apa yang dibuat oleh shell. Coba dengan cangkang yang berbeda juga.

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'

Dengan metode 1, tahapan utamanya adalah:

  1. tail membaca dan berusaha menemukan titik awalnya.
  2. tailmenulis potongan 4096-byte yang grepberbunyi secepat mereka diproduksi.
  3. Ulangi langkah sebelumnya untuk string pencarian kedua.

Dengan metode 2, tahapan utamanya adalah:

  1. tail membaca dan berusaha menemukan titik awalnya.
  2. tail menulis 4096-byte bash yang bash bertuliskan 128 byte pada satu waktu, dan zsh membaca 4096 byte sekaligus.
  3. Bash atau zsh menulis potongan 4096-byte yang grepberbunyi secepat yang dihasilkan.
  4. Ulangi langkah sebelumnya untuk string pencarian kedua.

Potongan 128 byte Bash ketika membaca output dari substitusi perintah memperlambatnya secara signifikan; zsh keluar secepat metode 1 untuk saya. Jarak tempuh Anda dapat bervariasi tergantung pada jenis dan nomor CPU, konfigurasi penjadwal, versi alat yang terlibat, dan ukuran data.

Gilles 'SANGAT berhenti menjadi jahat'
sumber
Apakah ukuran halaman gambar 4k tergantung? Maksud saya, apakah tail dan zsh keduanya hanya syscalls mmaping? (Mungkin itu terminologi yang salah, meskipun saya harap tidak ...) Apa yang dilakukan bash berbeda?
mikeserv
Ini tepat di Gilles! Dengan zsh, metode kedua sedikit lebih cepat di mesin saya.
phunehehe
Kerja bagus Gilles, tks.
X Tian
@ mikeserv Saya belum melihat sumber untuk melihat bagaimana program ini memilih ukuran. Alasan yang paling mungkin untuk melihat 4096 adalah konstanta bawaan ataust_blksize nilai untuk pipa, yaitu 4096 pada mesin ini (dan saya tidak tahu apakah itu karena ukuran halaman MMU). Bash's 128 harus berupa konstanta bawaan.
Gilles 'SO- stop being evil'
@Gilles, terima kasih atas jawaban bijaksana. Saya baru saja ingin tahu tentang ukuran halaman belakangan ini.
mikeserv
26

Saya telah melakukan tes berikut dan pada sistem saya perbedaan yang dihasilkan sekitar 100 kali lebih lama untuk skrip kedua.

File saya adalah output strace yang disebut bigfile

$ wc -l bigfile.log 
1617000 bigfile.log

Skrip

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

Saya sebenarnya tidak memiliki korek api untuk grep sehingga tidak ada yang ditulis ke pipa terakhir sampai wc -l

Berikut ini waktunya:

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

Jadi saya menjalankan dua skrip lagi melalui perintah strace

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

Berikut adalah hasil jejaknya:

$ 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

Dan 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

Analisis

Tidak mengherankan, dalam kedua kasus sebagian besar waktu dihabiskan menunggu proses untuk menyelesaikan, tetapi p2 menunggu 2,63 kali lebih lama dari p1, dan seperti yang telah disebutkan, Anda mulai terlambat di p2.sh.

Jadi sekarang lupakan waitpid , abaikan %kolom dan lihat kolom detik di kedua jejak.

Waktu terbesar p1 menghabiskan sebagian besar waktunya dalam membaca mungkin dapat dimengerti, karena ada file besar untuk dibaca, tetapi p2 menghabiskan 28,82 kali lebih lama dalam membaca daripada p1. - bashtidak mengharapkan untuk membaca file sebesar itu menjadi variabel dan mungkin membaca buffer pada satu waktu, membelah menjadi beberapa baris dan kemudian mendapatkan yang lain.

baca hitungan p2 adalah 705k vs 84k untuk p1, masing-masing read membutuhkan konteks switch ke ruang kernel dan keluar lagi. Hampir 10 kali jumlah bacaan dan konteks beralih.

Waktunya menulis p2 menghabiskan 41,93 kali lebih lama menulis daripada p1

tulis hitungan p1 tidak lebih banyak menulis daripada p2, 42k vs 21k, namun mereka jauh lebih cepat.

Mungkin karena echo garis-garis menjadi yang grepbertentangan dengan buffer penulisan ekor.

Lebih jauh lagi , p2 menghabiskan lebih banyak waktu dalam menulis daripada membaca, p1 adalah sebaliknya!

Faktor lain Lihatlah jumlah brkpanggilan sistem: p2 menghabiskan 2,42 kali lebih lama melanggar daripada membaca! Di p1 (bahkan tidak mendaftar).brkadalah ketika program perlu memperluas ruang alamatnya karena cukup tidak dialokasikan pada awalnya, ini mungkin karena bash harus membaca file itu ke dalam variabel, dan tidak berharap itu menjadi sebesar itu, dan seperti yang disebutkan @scai, jika file menjadi terlalu besar, bahkan itu tidak akan berhasil.

tailmungkin merupakan pembaca file yang cukup efisien, karena ini adalah apa yang dirancang untuk dilakukan, mungkin memmaps file dan memindai jeda baris, sehingga memungkinkan kernel untuk mengoptimalkan i / o. bash tidak sebagus waktu yang dihabiskan untuk membaca dan menulis.

p2 menghabiskan 44ms dan 41ms clonedan execvitu bukan jumlah yang terukur untuk p1. Mungkin bash membaca dan membuat variabel dari tail.

Akhirnya Totals p1 mengeksekusi ~ 150k panggilan sistem vs p2 740k (4,93 kali lebih besar).

Menghilangkan waitpid, p1 menghabiskan 0,014416 detik untuk mengeksekusi panggilan sistem, p2 0,439132 detik (30 kali lebih lama).

Jadi tampaknya p2 menghabiskan sebagian besar waktu di ruang pengguna tanpa melakukan apa pun kecuali menunggu panggilan sistem untuk menyelesaikan dan kernel untuk mengatur kembali memori, p1 melakukan lebih banyak menulis, tetapi lebih efisien dan menyebabkan beban sistem secara signifikan lebih sedikit, dan karenanya lebih cepat.

Kesimpulan

Saya tidak akan pernah mencoba khawatir tentang pengkodean melalui memori saat menulis skrip bash, itu tidak berarti mengatakan bahwa Anda tidak mencoba menjadi efisien.

taildirancang untuk melakukan apa yang dilakukannya, mungkin memory mapsfile tersebut sehingga efisien untuk dibaca dan memungkinkan kernel untuk mengoptimalkan i / o.

Cara yang lebih baik untuk mengoptimalkan masalah Anda mungkin dengan yang pertama grepuntuk '"sukses":' garis dan kemudian menghitung tanda dan kesalahan, grepmemiliki opsi menghitung yang lagi-lagi menghindari wc -l, atau bahkan lebih baik lagi, menyalurkan ekor melalui awkdan menghitung tanda dan falses bersamaan. p2 tidak hanya memakan waktu lama tetapi menambah beban ke sistem sementara memori sedang dikocok dengan brks.

X Tian
sumber
2
TL; DR: malloc (); jika Anda bisa memberi tahu $ log seberapa besar itu perlu dan bisa menulisnya dengan cepat dalam satu operasi tanpa realokasi, kemungkinan akan lebih cepat.
Chris K.
5

Sebenarnya solusi pertama membaca file ke dalam memori juga! Ini disebut caching dan secara otomatis dilakukan oleh sistem operasi.

Dan seperti yang sudah dijelaskan dengan benar oleh mikeserv , solusi pertama exectutesgrep ketika file sedang dibaca sedangkan solusi kedua mengeksekusi setelah file telah dibaca oleh tail.

Jadi solusi pertama lebih cepat karena berbagai optimasi. Tetapi ini tidak selalu harus benar. Untuk file yang benar-benar besar yang OS putuskan untuk tidak melakukan cache solusi kedua bisa menjadi lebih cepat. Tetapi perhatikan bahwa untuk file yang lebih besar yang tidak sesuai dengan memori Anda, solusi kedua tidak akan bekerja sama sekali.

scai
sumber
3

Saya pikir perbedaan utamanya sangat sederhana yaitu echolambat. Pertimbangkan ini:

$ 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

Seperti yang Anda lihat di atas, langkah yang memakan waktu adalah mencetak data. Jika Anda hanya mengarahkan ke file baru dan lihat itu jauh lebih cepat ketika hanya membaca file sekali.


Dan seperti yang diminta, dengan string di sini:

 $ 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

Yang ini bahkan lebih lambat, mungkin karena string di sini menggabungkan semua data ke satu garis panjang dan itu akan memperlambat 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

Jika variabel dikutip sehingga tidak terjadi pemisahan, hal-hal sedikit lebih cepat:

 $ 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

Tetapi masih lambat karena langkah pembatasan tingkat adalah mencetak data.

terdon
sumber
Mengapa tidak Anda coba <<<, akan menarik untuk melihat apakah itu membuat perbedaan.
Graeme
3

Saya sudah mencoba ini ... Pertama, saya membuat file:

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

Jika Anda menjalankan sendiri di atas, Anda harus menghasilkan 1,5 juta baris /tmp/logdengan rasio "success": "true"2:01 untuk"success": "false" garis.

Hal berikutnya yang saya lakukan adalah menjalankan beberapa tes. Saya menjalankan semua tes melalui proxy shbegitutime hanya perlu menonton satu proses - dan karena itu dapat menunjukkan hasil tunggal untuk seluruh pekerjaan.

Ini tampaknya menjadi yang tercepat, meskipun itu menambahkan deskriptor file kedua dan tee,meskipun saya pikir saya bisa menjelaskan mengapa:

    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

Inilah yang pertama Anda:

    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

Dan yang kedua:

    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

Anda dapat melihat bahwa dalam pengujian saya ada lebih dari 3 * perbedaan kecepatan ketika membacanya menjadi variabel seperti yang Anda lakukan.

Saya pikir bagian dari itu adalah bahwa variabel shell harus dipecah dan ditangani oleh shell ketika sedang dibaca - itu bukan file.

A here-documentdi sisi lain, untuk semua maksud dan tujuan, adalah file-file descriptor, pokoknya. Dan seperti yang kita semua tahu - Unix berfungsi dengan file.

Yang paling menarik bagi saya here-docsadalah bahwa Anda dapat memanipulasi mereka file-descriptors- secara langsung |pipe- dan mengeksekusinya. Ini sangat berguna karena memungkinkan Anda sedikit lebih banyak kebebasan dalam menunjukkan |pipetempat yang Anda inginkan.

Aku harus teeyang tailkarena pertama grepmakan yang here-doc |pipedan ada yang tersisa untuk yang kedua untuk membaca. Tetapi karena saya |pipedmemahaminya /dev/fd/3dan mengambilnya lagi untuk diberikan, >&1 stdout,itu tidak masalah. Jika Anda menggunakan grep -cbanyak orang lain merekomendasikan:

    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

Ini bahkan lebih cepat.

Tapi ketika saya menjalankannya tanpa . sourcingitu heredocsaya tidak bisa berhasil latar belakang proses pertama yang menjalankan mereka sepenuhnya secara bersamaan. Ini dia tanpa latar belakang sepenuhnya:

    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

Tetapi ketika saya menambahkan &:

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

Meski begitu, perbedaannya tampaknya hanya beberapa ratus detik, setidaknya bagi saya, jadi anggaplah seperti yang Anda mau.

Lagi pula, alasan mengapa ia berjalan lebih cepat teeadalah karena keduanya grepsberjalan pada saat yang sama dengan hanya satu permintaan tail. teeduplikat file untuk kami dan membagi ke yang keduagrep proses semua dalam-aliran - semuanya berjalan sekaligus dari awal hingga akhir, sehingga mereka semua selesai sekitar waktu yang sama juga.

Jadi kembali ke contoh pertama Anda:

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

Dan yang kedua:

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

Tetapi ketika kami membagi input kami dan menjalankan proses kami secara bersamaan:

          3>&1  | grep #now we're done
              /        
    tail | tee  #both process together
              \  
          >&1   | grep #now we're done
mikeserv
sumber
1
+1 tetapi tes terakhir Anda mati dengan kesalahan sintaksis, saya tidak berpikir waktunya benar di sana :)
terdon
@terdon Mereka mungkin salah - saya menunjukkan bahwa itu mati. Saya menunjukkan perbedaan antara & dan tidak & - ketika Anda menambahkannya, shell menjadi kesal. Tetapi saya melakukan banyak penyalinan / menempel sehingga saya mungkin mengacaukan satu atau dua, tapi saya pikir mereka baik-baik saja ...
mikeserv
sh: line 2: kesalahan sintaks dekat token tak terduga `| '
terdon
@terdon Ya itu - "Saya tidak bisa berhasil latar belakang proses pertama untuk menjalankannya secara bersamaan. Lihat?" Yang pertama tidak dilatar belakangi, tetapi ketika saya menambahkan & dalam upaya untuk melakukannya "token tak terduga." Ketika saya . sumber heredoc saya bisa menggunakan &.
mikeserv