あ...ありのまま 今 起こった事を話すぜ!
「おれは 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
そして cat
と grep "."
の実行速度の違いを比べてみます。
$ 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 版の gcat
と ggrep
で試してみました。
$ 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
read
、write
、wait4
で時間がかかっていることがわかります。
次に 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 の cat
、tr
、sed
、gawk
コマンドについては /dev/null
に書き込んでも読み込みがなくなることはありませんでした。長いので read
、write
、wait4
のみ記載します(その他の 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 年で更新が止まってるのが残念です。移転先とかないんでしょうか。