sed(1) VS printf(1) VS awk(1) 宿命の対決

テキストファイルに1行1個ずつ書かれているデータを、同じテンプレートを適用して出力したいのです。

例えば、テキストファイルの中身がこんな感じだと仮定する。

1
2
3

これを、こんな感じに変換する。

echo 1 !
echo 2 !
echo 3 !

こんな風に、何らかのテンプレートの中に元データを埋め込んで出力することを考える。

その昔、この手の処理に遭遇した時、sed(1)を使用して変換した。「s/^.*$/echo & !/」とか、そんな感じだ。しかし、何というか「これ、正規表現が必要なのか?」という疑問を覚える作業だ。もっとこう、複雑な解析と変換を行うのなら、正規表現を持ち出すのは妥当だと思うのだが、この程度のことに正規表現を使うのは大げさではないか、と思うのだ。

最近気づいたのがprintf(1)を使う技。printf(1)では、フォーマット文字列中の書式指定子よりも引数が多い場合、引数全てを消費するまで繰り返しフォーマット文字列が適用される。

$ printf '[ %d ]\n' 1 2 3
[ 1 ]
[ 2 ]
[ 3 ]

$ printf '[ %d : %s ]\n' 1 a 2 b 3 c
[ 1 : a ]
[ 2 : b ]
[ 3 : c ]

正規表現を持ち出すまでもない変換(例えばデータが空白文字で区切られている場合など)には、printf(1)も結構向いているのではないかと思うようになった。

ここで気になるのが、両者の処理速度。sed(1)はテキストフィルタだが、正規表現に基づくデータの解析を行う部分のコストがどの程度か気になる。printf(1)はテキストフィルタではないので、今回のような使い方ではxargs(1)と組み合わせて使うことになるし*1、書式指定子によってはデータの変換処理を行うはずなので、それらのコストが積み重なるとどの程度になるのか気になる。

という訳で、普段使用しているCygwin上で実験してみた。ちなみにCore i5-3340Mにメモリ16GBというハードウェア上で、Windows 7 Ultimate 64bit SP1を使用している。アンチウイルスソフトもインストール済みなので、プロセス生成コストが高めな環境だといえる。

$ uname -a
CYGWIN_NT-6.1 FABRICO 1.7.31(0.272/5/3) 2014-07-25 11:26 x86_64 Cygwin
$ seq --version
seq (GNU coreutils) 8.15
パッケージ作成者: Cygwin (8.15-3)
Copyright (C) 2012 Free Software Foundation, Inc.
ライセンス GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

作者 Ulrich Drepper。
$ sed --version
sed (GNU sed) 4.2.2
Copyright (C) 2012 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Jay Fenlason, Tom Lord, Ken Pizzini,
and Paolo Bonzini.
GNU sed ホームページ: <http://www.gnu.org/software/sed/>.
GNU ソフトウェアを使用する際の一般的なヘルプ: <http://www.gnu.org/gethelp/>.
電子メールによるバグ報告の宛先: <bug-sed@gnu.org>
報告の際、“Subject:” フィールドのどこかに “sed” を入れてください。
翻訳に関するバグは<translation-team-ja@lists.sourceforge.net>に報告してください。
$ printf --version
bash: printf: --: invalid option
printf: usage: printf [-v var] format [arguments]
$ bash --version
GNU bash, version 4.1.11(2)-release (x86_64-unknown-cygwin)
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$ time seq 1 10000000 | sed 's/^.*$/echo & !/' >/dev/null

real    1m35.535s
user    1m53.896s
sys     0m0.076s
$ time seq 1 10000000 | xargs printf 'echo %s !\n' >/dev/null

real    1m58.841s
user    2m33.513s
sys     0m33.136s
$ time seq 1 10000000 | xargs printf 'echo %d !\n' >/dev/null

real    1m58.279s
user    2m34.664s
sys     0m32.385s

実時間、CPU時間のどちらもsed(1)の方が少なく済んだ。seq(1)とsed(2)の組み合わせでは、処理の開始から終了まで2つのプロセスが動作する。それに対して、printf(1)を使用する場合は、処理の開始から終了まで2つのプロセス(seq(1)とxargs(1))が動作する上に、処理中に何度もprintf(1)のプロセスが生成される。プロセス生成コストの高さ故に、実時間もCPU時間も長くなっているのではないだろうか。

興味深いことに、printf(1)で書式指定子%sを使用した場合と%dを使用した場合とで、あまり差異が見られない。

この実験中に「あれ、この手の処理ってawk(1)でもいけるよね」と気づいたので、追試してみた。

$ awk --version
GNU Awk 4.1.1, API: 1.1 (GNU MPFR 3.1.2, GNU MP 6.0.0)
Copyright (C) 1989, 1991-2013 Free Software Foundation.

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see http://www.gnu.org/licenses/.
$ time seq 1 10000000 | awk '{ print "echo " $0 " !" }' >/dev/null

real    1m38.046s
user    1m45.830s
sys     0m0.124s
$ time seq 1 10000000 | awk '{ printf("echo %s !\n", $0) }' >/dev/null

real    1m36.642s
user    1m43.974s
sys     0m0.077s
$ time seq 1 10000000 | awk '{ printf("echo %d !\n", $0) }' >/dev/null

real    1m59.278s
user    3m48.696s
sys     0m0.046s

データを文字列として扱っている限りは、実時間はsed(1)よりわずかに大きいくらいで、CPU時間はsed(1)よりも少ない。printfで%dを使用した場合は、文字列から数値へ、数値から文字列へと二重に変換が発生するために、実時間・CPU時間共に長くなったのではないかと思う。

おまけとしてOpen usp Tukubaiのmojihame(1)で計測してみた。

$ python --version
Python 2.7.8
$ mojihame
Usage   : mojihame <template> <data>            (通常)
        : mojihame -l <label> <template> <data> (行単位)
        : mojihame -h <label> <template> <data> (階層データ)
Option  :  -d[c]
Version : Fri Oct 21 11:26:06 JST 2011
          Open usp Tukubai (LINUX+FREEBSD/PYTHON2.4/UTF-8)
$ echo 'echo %1 !' >template.txt
$ time seq 1 10000000 | mojihame -l template.txt >/dev/null

real    4m2.237s
user    5m39.442s
sys     0m6.721s

他の組み合わせよりも遅かった。もっとも、今回の実験の前提条件自体が少々特殊なので、あまり気にする必要は無いだろう。mojihameの魅力は、テンプレート適用の柔軟さや、テンプレート部分を別ファイルに分離できるところにある。usp Tukubaiの方のmojihameはC言語で実装されているはずなので、この結果よりも高速ではないかと思う。

ここまでは、シェルスクリプトらしくテキストフィルタを組み合わせてきた。最後にwhileループを試してみようと思う。ループ中で外部コマンドを実行する方法では、遅すぎて使い物にならないだろう。しかし内部コマンド(ビルトインコマンド)ならどうだろうか?

$ time seq 1 10000000 | while read i; do echo echo $i '!'; done >/dev/null

real    5m5.901s
user    5m16.993s
sys     1m32.789s

mojihame(1)よりも遅くなった! これでも内部コマンドのechoを使用しているので、随分とマシな結果だ。

$ time seq 1 100 | while read i; do echo echo $i '!'; done >/dev/null

real    0m0.047s
user    0m0.000s
sys     0m0.031s
$ time seq 1 100 | while read i; do /usr/bin/echo echo $i '!'; done >/dev/null

real    0m2.262s
user    0m0.166s
sys     0m1.297s

シェルスクリプトのforやwhileループが遅いと言われるのは、ループ中で外部コマンドを実行しているのが一番の原因であることが多いのだが、内部コマンドを使用しても遅いということから、「ループ自体も高速ではない」ないし「内部コマンドの呼び出しも高速とはいえない」と言えるだろう。まあ、bashだからかもしれないけど(ashやdashなら、もう少し速くなるのだろうか?)。

*1:xargs(1)のプロセスが生成される上に、データ数次第ではprintf(1)のプロセスが何度も生成・削除されることになる。