LoginSignup
39
17

More than 1 year has passed since last update.

GNU grep は /dev/null に出力すると、処理速度が異常に速くなる件について

Last updated at Posted at 2021-07-27

あ...ありのまま 今 起こった事を話すぜ!

「おれは cat の方が grep "." よりも速いことを示すために、両方の出力を
/dev/null に捨てたら grep の方だけ処理速度が異常に速くなっていた」

な・・・ 何を言っているのか わからねーと思うが 
おれも 何が起きたのか わからなかった・・・
頭がどうにかなりそうだった・・・ 催眠術だとか超スピードだとか
そんなチャチなもんじゃあ 断じてねえ
もっと恐ろしいものの片鱗を 味わったぜ・・・

  ん? いや、超スピードを味わったぜ

はじめに

何かのパフォーマンステストするときに、出力を画面やファイルに行うと速度が低下してしまうので、それを避けるために /dev/null に捨てるというのはよくある事だと思います。別件でとあるパフォーマンステストをしていたところ何やら不思議な結果がでてしまったので調べたのですが、どうやら GNU grep は出力先が /dev/null だと読み込みを行わないことがあるようです。読み込みデータがないので当然書き込みも一切なしです。

調べる前は /dev/null に出力するとデータは消えてしまうけど、書き込み(システムコール呼び出し)自体は普通に行われるもので、ましてや読み込みは普通に行われると思っていたので戸惑いました。

Linux で time による計測

まず適当なデータを作成します。以下は 1 行 50 バイト で 1000 万行、ファイルサイズ約 487 MB のテキストファイルを作成します。

$ seq -f %50.0f 10000000 > file.txt

そして catgrep "." の実行速度の違いを比べてみます。

$ time sh -c 'cat < file.txt > /dev/null'

real    0m0.063s
user    0m0.001s
sys     0m0.062s

$ time sh -c 'grep "." < file.txt > /dev/null'

real    0m0.002s
user    0m0.001s
sys     0m0.001s

いやいやいやいや、単純な正規表現とは言え検索処理をしている grep の方が cat より速いわけ無いでしょうと。もちろんデータ量を 10 倍にしても同じ結果です。いやこれはさすがにおかしい・・・。

macOS で計測

OS によるものなのだろうか?と思い、macOS 上で計測してみました。

$ seq -f %50.0f 10000000 > file.txt
$ time sh -c 'cat < file.txt > /dev/null'

real    0m0.105s
user    0m0.006s
sys     0m0.096s

$ time sh -c 'grep "." < file.txt > /dev/null'

real    0m4.547s
user    0m4.417s
sys     0m0.111s

grep異常に遅い以外におかしな所はありません。macOS 版の grep が遅いのは割と有名な話のようです。「macOS の grep が遅い」「散々既出で今更だけどmacOSデフォルトのgrepがめちゃ遅だった」「GNU grepが高速な理由

どうやら macOS 11.4 Big Sur では BSD 版の grep (grep (BSD grep) 2.5.1-FreeBSD) を使用しているようです。そう言えば FreeBSD では BSD 版 grep が遅いという理由で GNU grep を使い続けていたはずですが FreeBSD 13.0 で BSD 版に入れ替わったようです。これって遅い問題って解決したんでしたっけ?そういえば試してなかったです。

The BSD version of grep(1) is now installed by default. The obsolete GNU version that was the previous default has been removed.

どうやら FreeBSD 13.0 の grep は 2021 年版のようですね。

おまけ

All long options are provided for compatibility with GNU versions of this utility.

あー、そうか。今まで GNU 版の grep を使っていたから、速度を向上させるだけじゃなくて互換性のために、GNU 版のオプションを全て実装しなきゃならないのか。そういや macOS 版も grep はロングオプションが使えますね。BSD 版なのに grep だけは高機能w

閑話休題

/dev/null に出力すると、読み込み速度が速くなるのは、GNU 版の実装では?と思ったので、Homebrew でインストールした GNU 版の gcatggrep で試してみました。

$ time sh -c 'gcat < file.txt > /dev/null'

real    0m0.105s
user    0m0.006s
sys     0m0.096s

$ time sh -c 'ggrep "." < file.txt > /dev/null'

real    0m0.012s
user    0m0.004s
sys     0m0.006s

はい、正解のようです。GNU 版の grep だと cat よりも速くなりました。

strace によるシステムコール回数の確認

Linux に戻ります。まずは cat の結果です。

$ strace -f -c -o strace.txt -S name sh -c 'cat < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         2         2 access
  0.00    0.000000           0         4         2 arch_prctl
  0.00    0.000000           0         6           brk
  0.00    0.000000           0         1           clone
  0.01    0.000123           8        14           close
  0.00    0.000000           0         4           dup2
  0.00    0.000000           0         2           execve
  0.00    0.000000           0         1           fadvise64
  0.00    0.000000           0         4           fcntl
  0.00    0.000000           0         7           fstat
  0.00    0.000000           0         1           getegid
  0.00    0.000000           0         2           geteuid
  0.00    0.000000           0         1           getgid
  0.00    0.000000           0         1           getpid
  0.00    0.000000           0         1           getppid
  0.00    0.000000           0         1           getuid
  0.00    0.000000           0        16           mmap
  0.00    0.000000           0         8           mprotect
  0.01    0.000086          28         3           munmap
  0.00    0.000000           0         7           openat
  0.00    0.000000           0        12           pread64
 20.55    0.230552          59      3894           read
  0.00    0.000000           0         7           rt_sigaction
  0.00    0.000000           0         1           rt_sigreturn
  0.00    0.000000           0         8         5 stat
 64.80    0.726925      726925         1           wait4
 14.63    0.164127          42      3891           write
------ ----------- ----------- --------- --------- ----------------
100.00    1.121813                  7900         9 total

readwritewait4 で時間がかかっていることがわかります。

次に grep の結果です。

$ strace -f -c -o strace.txt -S name sh -c 'grep "." < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         2         2 access
  0.00    0.000000           0         4         2 arch_prctl
  0.00    0.000000           0         6           brk
  0.00    0.000000           0         1           clone
  0.00    0.000000           0        19           close
  0.00    0.000000           0         4           dup2
  0.00    0.000000           0         2           execve
  0.00    0.000000           0         4           fcntl
  0.00    0.000000           0        13           fstat
  0.00    0.000000           0         1           futex
  0.00    0.000000           0         1           getegid
  0.00    0.000000           0         2           geteuid
  0.00    0.000000           0         1           getgid
  0.00    0.000000           0         1           getpid
  0.00    0.000000           0         1           getppid
  0.00    0.000000           0         1           getuid
  0.00    0.000000           0         5           lseek
  0.00    0.000000           0        31           mmap
  0.00    0.000000           0        11           mprotect
  0.00    0.000000           0         2           munmap
  0.00    0.000000           0        24        11 openat
  0.00    0.000000           0        14           pread64
  0.00    0.000000           0         1           prlimit64
  0.00    0.000000           0         8           read
  0.00    0.000000           0        10           rt_sigaction
  0.00    0.000000           0         1           rt_sigprocmask
  0.00    0.000000           0         1           rt_sigreturn
  0.00    0.000000           0         1           set_robust_list
  0.00    0.000000           0         1           set_tid_address
  0.00    0.000000           0         1           sigaltstack
  0.00    0.000000           0         9         5 stat
  0.00    0.000000           0         1           wait4
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000                   184        20 total

read の回数がほとんどなくなってしまい、write は 1 回も行われませんでした。

正確にはマッチした行以降を読み込まない

grep はどの行にもマッチしなければ終了ステータス 1 で終了しなければいけません。grep "." で検索した場合は必ずマッチするためほとんどの行を読み込みませんでしたが、どの行にもマッチしない場合はすべての行を読み込みます。ただし /dev/null への書き込みはやっぱり行いません。

-q (--quiet, --silent) オプションと同じ動作

POSIX でも規定されている-q オプションを使うと出力が行われなくなります。これを使うと /dev/null に捨てたのと同じように(マッチした行以降は)読み込みを行わないようです。これは macOS でも同じようです(実行速度から読み込みも書き込みも行われていないと推測していますが、実際のシステムコール呼び出し回数は調べていません)。

こちらの記事「シェルスクリプトでファイルに特定の文字が含まれているかどうかを高速に判定する方法」では /dev/null に捨てるよりも -q を使用したほうが高速であるという検証結果が書かれているのですが、(bash のバージョン番号からおそらく CentOS 7 で)使用している GNU grep のバージョンが 2.20 と古く、/dev/null に捨てても速くなったのは 2.26 からです。とは言っても macOS や他の環境を考えるとやはり今も高速に判定するならば -q を使用したほうが良いでしょう。

どこで修正が入ったのか?

ソースコードはちゃんと読んでいませんが grep/dev/null へ出力した場合に、マッチした行以降の読み込みをしないようにした修正は、これだと思います。

grep: /dev/null output speedup

This sped up 'seq 10000000000 | grep . >/dev/null' by a factor of
380,000 on my platform (Fedora 23, x86-64, AMD Phenom II X4 910e,
en_US.UTF-8 locale).

上記のコミットは GNU grep 2.26 に含まれていて、2.20 を搭載している Debian 8 では遅いままでしたが、2.27 を搭載している Debian 9 (2017年) では /dev/null に捨てると速くなりました。どうやら GNU grep 2.26 から /dev/null に捨てても -q オプションを付けたのと同じ動作を行うようになったと考えれば良さそうです。

しかし修正の意図はわかるし実際に速くなりますが、直感的な動作ではないしこの修正で嬉しい人っているんでしょうかね? -q だけの対応でよかったような。そもそも標準出力の出力先デバイスの違いで処理を変えるべきではないように思います。

他のコマンドはどうなの?

気になるところですが、少なくとも Ubuntu 20.04 の cattrsedgawk コマンドについては /dev/null に書き込んでも読み込みがなくなることはありませんでした。長いので readwritewait4 のみ記載します(その他の time ほぼ 0% です)。

$ strace -f -c -o strace.txt -S name sh -c 'cat < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 20.62    0.239481          61      3894           read
 64.54    0.749775      749775         1           wait4
 14.47    0.168058          43      3891           write
------ ----------- ----------- --------- --------- ----------------
100.00    1.161639                  7900         9 total
$ strace -f -c -o strace.txt -S name sh -c 'tr a a < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 11.48    2.712172          43     62259           read
 66.98   15.825157    15825157         1           wait4
 21.54    5.088546          40    124511           write
------ ----------- ----------- --------- --------- ----------------
100.00   23.626854                186883        10 total
$ strace -f -c -o strace.txt -S name sh -c 'sed s/a/a/ < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 16.34    4.826012          38    124522           read
 68.20   20.146869    20146869         1           wait4
 15.46    4.567744          36    124512           write
------ ----------- ----------- --------- --------- ----------------
100.00   29.541500                249209        13 total
$ strace -f -c -o strace.txt -S name sh -c 'gawk "{ print \$0 }" < file.txt > /dev/null'

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 16.19    4.617019          37    124526           read
 68.49   19.532517    19532517         1           wait4
 15.30    4.363747          35    124512           write
------ ----------- ----------- --------- --------- ----------------
100.00   28.519869                249246        12 total

副作用(標準出力以外への書き込み等)がなければ /dev/null に捨てる場合に読み込む意味がないというのはわかります。この考えに従えば cat は真っ先に高速化されそうな気がします。tr も副作用はなさそうです。GNU 版の sed はどうなんだろう?拡張機能でファイル書き込み機能とかがあるかもしれません。awk に関しては副作用がありえるので将来的にも読み込みをしなくなることはないでしょう。まあともかく他のコマンドもこのような挙動をする可能性は否定できないので気に留めていた方が良さそうです。

ちなみに GNU tar に関してはこのような記事を見つけました。「tar の書き出し先が /dev/null だと file を読み書きしない - gnu tar のソースに dev_null_output 変数がある」(私はこれを検証していません。)

まとめ

ということで GNU grep (2.26 以降)で標準出力を /dev/null に捨てると、マッチした行以降は書き込みも読み込みも行われないのでパフォーマンステスト等では注意しましょうという話でした。よくよく考えてみると /dev/null に捨てる場合でもシステムコールは呼び出されているわけで、何も出力しないよりも負荷が高いんですよね。考えればわかるけど見逃していた気がします。

 
 

・・・という記事を書き上げて再度調べてみたら「GNU grep 2.26リリース」の記事で「/dev/nullへの出力に対する性能改善」について言及されていました。書く前によく調べろっていう話ですね。もしこれを先に見つけていたら、いろいろと遠回りすることはなかったでしょう。ですがもう書いてしまったので公開します。調査方法やら関連する話も書いてあるから誰かの役に立つし(震え声)

なおこちらの記事では

ただ、「/dev/null」はハードコードされているため、/dev/nullがキャラクタ・デバイスであれば中身がなんであれ本機能が動作していしまいます。/dev/nullを置き換えれば多くのプログラムが動作しなくなるため普通はそのようなことをしないでしょうが。

と書かれていますが/dev/null は POSIX でも規定されているためその心配はないでしょう。それにしてもこちらのブログ、記事の数自体は多くはないですが grep や正規表現周りの良い記事ばかりですね。2016 年で更新が止まってるのが残念です。移転先とかないんでしょうか。

39
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
17