为什么遍历文件比将文件读入内存和计算两次快两倍?


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次,而在第一个示例中只有2次?
Laurent C.

4
至少在第二个示例中,您$( command substitution )流式传输。其余所有操作都是通过管道同时进行的,但是在第二个示例中,您必须等待log=完成。与<< HERE \ n $ {log = $(command)} \ nHERE一起尝试-看看会得到什么。
mikeserv 2014年

如果文件过大,内存受限制的计算机或要处理的更多项目grep,则可能会看到使用速度有所提高,tee因此该文件肯定只能读取一次。cat stdout.log | tee >/dev/null >(grep -c 'true'>true.cnt) >(grep -c 'false'>false.cnt); cat true.cnt; cat false.cnt
马特2014年

@LaurentC。,不,在第二个示例中仅读取了一次。尾巴只有一个。
psusi 2014年

现在将这些与tail -n 10000 | fgrep -c '"success": true'和进行比较。
2014年

Answers:


11

一方面,第一个方法调用tail两次,因此它比第二个方法仅执行一次的工作更多。另一方面,第二种方法必须将数据复制到外壳中,然后再撤出,因此与tail直接管道传输到中的第一种版本相比,它必须做更多的工作grep。第一种方法在多处理器计算机上具有一个额外的优势:grep可以与并行工作tail,而第二种方法则首先严格序列化tail,然后再严格序列化grep

因此,没有明显的理由可以使一个比另一个更快。

如果要查看发生了什么,请查看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'

使用方法1,主要阶段是:

  1. tail 阅读并寻求找到起点。
  2. tail写入4096字节的块,其grep读取速度与产生的速度相同。
  3. 对第二个搜索字符串重复上一步。

使用方法2,主要阶段为:

  1. tail 阅读并寻求找到起点。
  2. tail 写入4096字节的块,bash一次读取128字节,而zsh一次读取4096字节。
  3. Bash或zsh写入4096字节的块,其grep读取速度与生成的速度一样快。
  4. 对第二个搜索字符串重复上一步。

当读取命令替换的输出时,Bash的128字节块会大大降低其速度;对我来说,zsh的出现速度与方法1一样快。您的里程可能会有所不同,具体取决于CPU类型和数量,调度程序配置,所涉及工具的版本以及数据大小。


4k图形的页面大小是否相关?我的意思是,tail和zsh都只是映射系统调用吗?(这可能是不正确的术语,尽管我希望不是...)bash的工作方式有何不同?
mikeserv

这是吉尔(Gilles)上的景点!使用zsh时,第二种方法在我的机器上稍快一些。
phunehehe

吉尔斯(Tick)很棒。
2014年

@mikeserv我还没有看过源代码,无法查看这些程序如何选择大小。看到4096的最可能原因是内置常数或st_blksize管道的值,在此机器上是4096(我不知道那是因为这是MMU页面大小)。Bash的128必须是一个内置常数。
吉尔斯(Gilles)'“ SO-不要邪恶”

@吉尔斯,感谢您的深思熟虑。我最近对页面大小感到好奇。
mikeserv

26

我已经完成了以下测试,并且在我的系统上,第二个脚本的结果差异大约长100倍。

我的文件是一个名为strace的输出 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

我实际上没有与grep匹配的任何东西,因此没有任何内容写入到的最后一个管道中 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等待的时间比p1长2.63倍,并且正如其他人所提到的,您在p2.sh中起步较晚。

因此,现在忘记了waitpid,忽略该%列,而查看两条迹线的秒数列。

可以理解,最长的时间 p1花费了大部分时间,这是因为可以读取的文件很大,但是p2的读取时间是p1的28.82倍。- bash不会期望将如此大的文件读入一个变量,并且可能一次读取缓冲区,分成几行然后再获取另一个。

读取计数 p2为705k,而p1为84k,每次读取都需要将上下文切换到内核空间然后再移出。读取和上下文切换次数接近10倍。

在写入时间 P2在写比P1花费41.93倍以上

写计数 p1比p2进行的操作更多,分别为42k和21k,但是它们要快得多。

可能是因为与尾写缓冲区相对应echo的行数grep

而且,p2在写上花费的时间比在读上花费的时间更多,p1相反!

其他因素查看brk系统调用的数量:p2的中断时间比读取时间长2.42倍!在p1中(甚至不注册)。brk是因为程序最初没有分配足够的空间而需要扩展其地址空间时,这可能是由于bash必须将该文件读取到变量中,并且不希望它那么大,并且如@scai所述,如果文件变得太大,即使那样也无法正常工作。

tail可能是一种非常有效的文件读取器,因为这是它设计的目的,它可能会对文件进行内存映射并扫描换行符,从而使内核可以优化I / O。bash的阅读和写作时间都不够好。

p2花费了44ms和41ms的时间cloneexecv对于p1来说这不是可测量的量。大概bash读取并从尾部创建变量。

最终,总计 p1执行〜150k系统调用,而p2则执行740k(4.93倍)。

消除了waitpid,p1花费了0.014416秒执行系统调用,p2花费了0.439132秒(长30倍)。

因此,p2似乎在用户空间中大部分时间都花在了其他事情上,除了等待系统调用完成和内核重新组织内存之外,p1执行更多的写入操作,但是效率更高,并且显着减少了系统负载,因此速度更快。

结论

编写bash脚本时,我永远不会担心通过内存进行编码,这并不意味着您没有在尝试提高效率。

tail设计用于执行其操作,可能memory maps是处理文件,以便高效读取并允许内核优化I / O。

优化问题的一种更好的方法可能是先grep处理'“ success”:'行,然后计算是非值,grep并具有一个count选项,该选项再次避免了wc -l,甚至更好的是,将尾巴通过awk并计算出true和并发错误。p2不仅要花费很长时间,而且还会在内存被brks搅乱时增加系统的负载。


2
TL; DR:malloc(); 如果您可以告诉$ log它需要多大,并且可以在没有重新分配的情况下快速写入一次op中,那么它可能会一样快。
克里斯K

5

实际上,第一个解决方案也将文件读入内存!这称为缓存,由操作系统自动完成。

并为已经正确地解释mikeserv第一个解决方案exectutes 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

如您在上面看到的,耗时的步骤是打印数据。如果你简单地重定向到一个新的文件和grep通过它快时,只有一次读取文件。


并按要求使用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

这甚至更慢,大概是因为here字符串将所有数据连接到一条长行上,这会减慢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 2014年

3

我也尝试了一下...首先,我建立了文件:

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

如果您自己进行上述操作,则应该得出150万行/tmp/log"success": "true"行与"success": "false"行的比例为2:1 。

我接下来要做的是运行一些测试。我通过代理运行了所有测试,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 *。

我认为部分原因是读取时必须将shell变量拆分和处理,而不是文件。

一个here-document在另一方面,对于所有意图和目的,file-一个file descriptor,反正。众所周知,Unix可处理文件。

什么是最有趣的给我介绍here-docs的是,你可以操纵自己的file-descriptors-作为一个直|pipe-并执行它们。这非常方便,因为它使您可以更加自由地指向所需|pipe位置。

我之所以必须这样做,teetail因为第一个grep吃了here-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

它甚至更快。

但是,如果没有运行它. sourcingheredoc我将无法成功地使第一个进程同时完全运行。这里没有完全背景化:

    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他们可能是错的-我指出它已经死了。我展示了&与否&之间的区别-添加它时,shell会不高兴。但是我做了很多复制/粘贴操作,所以我可能会弄乱一两个,但是我认为它们没问题……
mikeserv

sh:第2行:意外令牌'|'附近的语法错误
terdon

@terdon是的-“我无法成功使第一个进程完全同时运行的后台运行。看到了吗?” 第一个没有背景,但是当我添加&试图这样做时,它是“意外令牌”。当我 。来源Heredoc我可以使用&。
mikeserv
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.