Чому ітерація над файлом удвічі швидша, ніж його читання в пам'яті та обчислення двічі?


26

Я порівнюю наступне

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

із наступним

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

і дивно, що другий займає майже в 3 рази більше, ніж перший. Це повинно бути швидше, чи не так?


Чи може це бути тому, що в другому рішенні вміст файлу читається 3 рази, і в першому прикладі лише два рази?
Лоран К.

4
Принаймні у другому прикладі, ваш не$( command substitution ) передається в потоковому режимі. Все інше відбувається по трубах одночасно, але у другому прикладі потрібно чекати завершення. Спробуйте це з << ТУТ \ n $ {log = $ (команда)} \ nТУДІ - подивіться, що ви отримаєте. log=
mikeserv

У випадку надзвичайно великих файлів, машин з обмеженою пам’яттю або інших елементів grepдля, можливо, ви побачите деяке прискорення використання, teeтому файл, безумовно, читається лише один раз. cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
Метт

@LaurentC., Ні, він читається лише один раз у другому прикладі. Є лише один дзвінок у хвіст.
psusi

Тепер порівняйте ці tail -n 10000 | fgrep -c '"success": true'помилкові.
Кідро

Відповіді:


11

З одного боку, перший метод викликає tailдвічі, тому він повинен виконати більше роботи, ніж другий метод, який робить це лише один раз. З іншого боку, другий метод повинен скопіювати дані в оболонку, а потім назад, тому він повинен виконати більше роботи, ніж перша версія, де tailбезпосередньо вкладено grep. Перший метод має додаткову перевагу на багатопроцесорній машині: grepможе працювати паралельно tail, тоді як другий метод суворо серіалізується, спочатку tail, потімgrep .

Тож немає очевидних причин, чому один повинен бути швидшим за інший.

Якщо ви хочете побачити, що відбувається, подивіться, що система викликає оболонку. Спробуйте і з різними оболонками.

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'

Основними етапами методу 1 є:

  1. tail читає і прагне знайти свою вихідну точку.
  2. tail пише 4096-байтних фрагментів, які grep читаються так само швидко, як і створені.
  3. Повторіть попередній крок для другого рядка пошуку.

Метод 2 основними етапами є:

  1. tail читає і прагне знайти свою вихідну точку.
  2. tail пише 4096-байтні фрагменти, в яких bash читає 128 байт за один раз, а zsh читає 4096 байт за один раз.
  3. Bash або zsh пише 4096-байтні фрагменти, які grep читаються так само швидко, як і створені.
  4. Повторіть попередній крок для другого рядка пошуку.

128-байтовий фрагмент Баша при зчитуванні результатів заміни команди значно сповільнює його; zsh виходить приблизно так само швидко, як і метод 1 для мене. Пробіг може змінюватись залежно від типу та кількості процесора, конфігурації планувальника, версій залучених інструментів та розміру даних.


Чи залежить розмір сторінки 4k рисунка? Я маю на увазі, хвіст і zsh обидва просто копіюють системні дзвінки? (Можливо, це неправильна термінологія, хоча я сподіваюся, що ні ...) Що баш робить інакше?
mikeserv

Це місце на Жилі! З zsh другий метод трохи швидший на моїй машині.
phunehehe

Чудова робота Жиль, ткс.
X Тянь

@mikeserv Я не дивився на джерело, щоб побачити, як ці програми вибирають розмір. Найбільш вірогідними причинами побачити 4096 буде вбудована константа або st_blksizeзначення для труби, яка 4096 на цій машині (і я не знаю, чи це тому, що це розмір сторінки MMU). Баш 128 повинен був бути вбудованою постійною.
Жил "ТАК - перестань бути злим"

@Gilles, дякую за продуману відповідь. Я останнім часом просто цікавився розмірами сторінок.
mikeserv

26

Я зробив наступний тест, і в моїй системі отримана різниця приблизно в 100 разів довша для другого сценарію.

Мій файл - це виклик страйку bigfile

$ wc -l bigfile.log 
1617000 bigfile.log

Сценарії

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

Насправді у мене немає жодних збігів на греп, тому нічого не записується в останню трубку через wc -l

Ось терміни:

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

Тому я запустив два сценарії знову за допомогою команди strace

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

Ось результати від слідів:

$ 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

І 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

Аналіз

Не дивно, що в обох випадках більша частина часу витрачається на очікування завершення процесу, але p2 чекає в 2,63 рази довше, ніж p1, і, як уже згадували інші, ви починаєте пізно в p2.sh.

Тому тепер забудьте про waitpid, ігноруйте %стовпчик і подивіться на стовпці секунд на обох слідах.

Найбільший час p1 проводить більшу частину свого часу на читання, ймовірно, зрозуміло, тому що для читання є великий файл, але p2 витрачається на 28,82 рази довше, ніж p1. - bashне сподівається прочитати такий великий файл у змінній і, ймовірно, читає буфер за раз, розбиваючись на рядки, а потім отримуючи інший.

кількість читання p2 становить 705k проти 84k для p1, кожне читання вимагає переключення контексту на простір ядра та знову. Майже в 10 разів перевищує кількість зчитувань та перемикань контексту.

Час запису p2 витрачає на запис у 41,93 рази більше, ніж p1

кількість записів p1 робить більше записів, ніж p2, 42k проти 21k, проте вони набагато швидші.

Можливо, через echoрядки в, grepа не в буфери для написання хвоста.

Крім того , p2 витрачає більше часу на запис, ніж у читанні, p1 - навпаки!

Інший фактор Подивіться на кількість brkсистемних дзвінків: p2 витрачає 2,42 рази більше часу, ніж читання! У p1 (він навіть не реєструється). brkколи програма повинна розширити свій адресний простір, оскільки спочатку не було виділено достатньо місця, це, ймовірно, пов’язано з тим, що баш повинен прочитати цей файл у змінній, і не очікуючи, що він буде таким великим, і як згадував @scai, якщо файл стає занадто великим, навіть це не працює.

tailце, мабуть, досить ефективний зчитувач файлів, тому що це те, що він був розроблений, він, ймовірно, запам’ятовує файл і сканує розриви рядків, таким чином, дозволяючи ядру оптимізувати введення-виведення. bash не так добре, як на час, витрачений на читання та письмо.

p2 витрачає 44 мс і 41 мс, cloneі execvце не вимірюється кількість для p1. Можливо, читання башти та створення змінної від хвоста.

Нарешті Totals p1 виконує ~ 150k системних викликів проти p2 740k (у 4,93 рази більше).

Усуваючи чеканку, p1 витрачає 0,014416 секунд, виконуючи системні виклики, p2 0,439132 секунди (у 30 разів довше).

Таким чином, схоже, p2 проводить більшу частину часу в просторі користувача, не роблячи нічого, крім очікування завершення системних викликів і ядра для реорганізації пам'яті, p1 виконує більше записів, але є більш ефективним і викликає значно меншу завантаженість системи, а значить, швидше.

Висновок

Я б ніколи не намагався турбуватися про кодування через пам'ять під час написання bash-сценарію, це не означає сказати, що ви не намагаєтесь бути ефективними.

tailпризначений для того, щоб робити те, що він робить, він, ймовірно, memory mapsфайл, щоб він був ефективним для читання і дозволяє ядру оптимізувати введення / виведення.

Кращим способом оптимізувати вашу проблему може бути спочатку grepдля рядків "" успіху ":", а потім підраховувати підрахунки і помилки, grepє варіант підрахунку, який знову дозволяє уникнути wc -l, а ще краще, пропустити хвіст до awkі підрахувати підрахунки і помилково одночасно. p2 не тільки займає багато часу, але додає навантаження на систему, в той час як пам'ять перетасовується за допомогою brks.


2
TL; DR: malloc (); якби ви могли сказати $ log, наскільки він повинен бути великим, і зможете швидко записати його в одну опцію без перерозподілу, вона, ймовірно, буде такою швидкою.
Кріс К

5

Насправді перше рішення також читає файл у пам'ять! Це називається кешування і автоматично робиться операційною системою.

І як вже правильно пояснив mikeserv, перше рішення виконується grep під час читання файлу, тоді як друге рішення виконує його після того, як файл був прочитаний tail.

Тож перше рішення швидше через різні оптимізації. Але це не завжди повинно бути правдою. Для дійсно великих файлів, ОС яких вирішує не кешувати, друге рішення може стати швидшим. Але зауважте, що для ще більших файлів, які не вмістяться у вашій пам'яті, друге рішення взагалі не працюватиме.


3

Я думаю, що головна відмінність дуже просто в тому, що echoце повільно. Врахуйте це:

$ 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

Як ви бачите вище, кроком, що займає багато часу, є друк даних. Якщо ви просто переспрямовуєтесь на новий файл і проглядаєте його, це набагато швидше, коли ви читаєте файл лише один раз.


І як вимагається, рядок тут:

 $ 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

Цей ще повільніше, мабуть тому, що рядок тут об'єднує всі дані в один довгий рядок, і це сповільнить 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

Якщо змінна цитується так, що не відбувається розщеплення, все відбувається трохи швидше:

 $ 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

Але все-таки повільно, оскільки крок обмеження швидкості - це друк даних.


Чому б не спробувати, <<<було б цікаво подивитися, чи це має значення.
Graeme

3

Я також хотів і в цьому ... По-перше, я створив файл:

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

Якщо ви виконаєте вищезазначене, вам слід створити 1,5 мільйона рядків /tmp/logіз співвідношенням "success": "true"рядків 2: 1 "success": "false".

Наступне, що я зробив - це провести кілька тестів. Я пройшов усі тести через проксі, shтому timeслід було б дивитися лише один процес - і тому міг показати єдиний результат для всієї роботи.

Це здається найшвидшим, навіть якщо він додає другий дескриптор файлу, і tee,хоча я думаю, я можу пояснити, чому:

    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

Ось ваш перший:

    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

І ваше друге:

    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

Ви бачите, що в моїх тестах було більше 3 * різниці в швидкості, коли читали його в змінну, як і ви.

Я думаю, що частиною цього є те, що змінна оболонки повинна бути розділена і оброблена оболонкою під час її читання - це не файл.

here-documentЗ іншого боку, для всіх намірів і цілей, це file- цеfile descriptor, в будь-якому випадку. І як ми всі знаємо - Unix працює з файлами.

Що для мене найцікавіше - here-docsце те, що ти можеш маніпулювати ними file-descriptors- як прямими |pipe- і виконувати їх. Це дуже зручно, оскільки дозволяє трохи більше свободи в орієнтуванні на те, |pipeде ви хочете.

Я повинен був , тому що перші з'їдає і там нічого не залишилося за секунду для читання. Але так як я це взяв і знову взяв його, щоб перейти до нього, це не мало значення. Якщо ви використовуєте як багато інших рекомендують:teetailgrephere-doc |pipe|piped/dev/fd/3>&1 stdout,grep -c

    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

Це ще швидше.

Але коли я запускаю його без . sourcingв heredocI не може успішно фон перший процес , щоб запустити їх в повному обсязі одночасно. Ось це не повністю з цим фоном:

    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

Але коли я додаю &:

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

І все-таки різниця, здається, становить лише кілька сотих секунди, принаймні для мене, тому прийміть це так, як хочете.

У будь-якому випадку, причина, з якою вона працює швидше, teeполягає в тому, що обидва grepsпрацюють одночасно лише одним викликом tail. teeдублікатів файлу для нас і розбиває його на другий grepпроцес все в потоці - все працює одразу від початку до кінця, тому вони всі теж закінчуються приблизно в один і той же час.

Тож повертаємось до вашого першого прикладу:

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

І ваше друге:

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

Але коли ми розділимо свій внесок і одночасно запустимо наші процеси:

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

1
+1, але ваш останній тест помер із синтаксичною помилкою, я не думаю, що часи там є правильними :)
terdon

@terdon Вони можуть помилятися - я вказував, що він помер. Я показав різницю між & і ні & - коли ви додасте його, оболонка засмучується. Але я зробив багато копій / вклеювання, тому я, можливо, зіпсував одну-дві, але, думаю, у них все гаразд ...
mikeserv

sh: рядок 2: помилка синтаксису біля несподіваного маркера `| '
тердон

@terdon Так - "Я не можу успішно виконати перший процес, щоб запустити їх повністю одночасно. Бачите?" Перший не фоновий, але коли я додаю & намагаюся зробити це "несподіваний маркер". Коли я . джерело гередока, я можу використовувати &.
mikeserv
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.