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

あ．．．ありのまま 今　起こった事を話すぜ！

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

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

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

## はじめに

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

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

## Linux で `time` による計測

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

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

そして `cat` と `grep "."` の実行速度の違いを比べてみます。

```console
$ 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 上で計測してみました。

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

```console
$ 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 が遅い](https://qiita.com/tohta/items/1e105580a642bfe814d2)」「[散々既出で今更だけどmacOSデフォルトのgrepがめちゃ遅だった](https://amaya382.hatenablog.jp/entry/2017/12/25/180700)」「[GNU grepが高速な理由](https://news.mynavi.jp/article/20100825-why-gnu-grep-is-fast-than-bsd/)」 

どうやら macOS 11.4 Big Sur では BSD 版の grep （`grep (BSD grep) 2.5.1-FreeBSD`） を使用しているようです。そう言えば FreeBSD では BSD 版 grep が遅いという理由で GNU grep を使い続けていたはずですが [FreeBSD 13.0 で BSD 版に入れ替わった](https://www.freebsd.org/releases/13.0R/relnotes/)ようです。これって遅い問題って解決したんでしたっけ？そういえば試してなかったです。

> 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](https://www.freebsd.org/cgi/man.cgi?query=grep&sektion=1&format=html) は 2021 年版のようですね。

おまけ

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

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

閑話休題

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

```console
$ 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` の結果です。

```console
$ 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` の結果です。


```console
$ 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 でも同じようです（実行速度から読み込みも書き込みも行われていないと推測していますが、実際のシステムコール呼び出し回数は調べていません）。

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

## どこで修正が入ったのか？

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

[grep: /dev/null output speedup](https://git.savannah.gnu.org/cgit/grep.git/commit/?id=af6af288eac28951b5eee1eaaf373e22b2193b7b)

```
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% です）。

```console
$ 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
```

```console
$ 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
```

```console
$ 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
```

```console
$ 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 変数がある](https://plaza.rakuten.co.jp/ftechworks/diary/202005170000/)」（私はこれを検証していません。）

## まとめ

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

　
　

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

なおこちらの記事では

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

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