2015年12月10日木曜日

そこまで遅くないShellスクリプトの書き方

この記事は Shell Script Advent Calendar 2015 10日目の記事です。
9日目の記事はryoana14さんの麗しきawkの世界でした。

Shellスクリプトがいつまで経ってもまともに書けないMasWagです。書けないなりにも人の書いた(昔の自分が書いたものも多く含む)スクリプトを見てこれは遅いなと思うことはたまにあります。書き方のコツというか考え方が幾つかあると思うのでまとめてみようと思います。基本的な話なので多分Shellスクリプトをあんまり普段書かない人向けだと思います。ShellはBashを前提として書きます。zshだともっと色々できるのかもしれないです。細かい説明は(そんなに細かくなくても?)省いているので適宜manやinfoを参照すると良いでしょう。

forkを減らす

Shellでコマンドを使うということは多くの場合プロセスをforkしていることになります。Cygwinでプロセスを立てるコストが非常に高いということが問題になったりしますが、*nixでもプロセスを立てるコストは多くの場合無視できません。そうとなるとshellの組み込みの機能でできることはやりたくなります。 Bashの組込み機能で使用頻度が高そうなものを挙げていきます。

まず数式処理"(())"があります。これは大体exprの代替だと思って間違いないです。案外使える機能に((t++))でインクリメントできたり((t+=2))のようなものもあります。

下の結果を見ると解ると思いますが、やはりforkの回数を減らすと目に見えて速度が速くなります。一回だけ実行するタイプではbcもかなり健闘しています。

#!/bin/bash
s=0
time for a in $(seq 0 1000); do
s=$(echo $s + $a | bc);
done
echo $s
s=0
time for a in $(seq 0 1000); do
s=$( expr $s + $a);
done
echo $s
s=0
time for a in $(seq 0 1000); do
((s += $a));
done
echo $s
s=0
time s=$(seq -s + 0 1000 | bc)
echo $s
s=0
time s=$(expr $(seq -s " + " 0 1000))
echo $s
s=0
time s=$(($(seq -s + 0 1000)))
echo $s
s=0
view raw a.sh hosted with ❤ by GitHub
real 0m2.002s
user 0m0.070s
sys 0m0.197s
500500
real 0m1.308s
user 0m0.047s
sys 0m0.173s
500500
real 0m0.007s
user 0m0.007s
sys 0m0.000s
500500
real 0m0.003s
user 0m0.000s
sys 0m0.000s
500500
real 0m0.006s
user 0m0.000s
sys 0m0.000s
500500
real 0m0.002s
user 0m0.003s
sys 0m0.000s
500500
view raw a.log hosted with ❤ by GitHub

次にtest/[]の代替の[[]]があります。過去の記事にもあるように単にtestの代替として使うだけでもちょっと速くなりますが、実は[[]]は正規表現マッチができます。grepを使って正規表現マッチしている分岐があったら書き直した方がいいです。またこれは正規表現の話ですが、複数の正規表現のorで分岐するならひとつの正規表現にまとめた方がいいです。

下の結果のようにまあ[[]]を使うと速いです。testとの比較だとそこまで時間が変わっていないですがgrepとの比較だとかなり時間に差が出てしまいます。

#!/bin/bash
s=0;
# とても回数が多いことに注意
time for a in {0..100000}; do
if [ $a == "0" ] ; then
((s++));
fi
done
echo $s
s=0;
# とても回数が多いことに注意
time for a in {0..100000}; do
if [[ $a == "0" ]] ; then
((s++));
fi
done
echo $s
s=0;
# 回数は減らしました
time for a in {0..100}; do
if echo $a | grep "0" > /dev/null; then
((s++));
fi
done
echo $s
s=0;
# 回数は減らしました
time for a in {0..100}; do
if [[ $a =~ 0 ]] ; then
((s++));
fi
done
echo $s
s=0;
# 回数は多いです
time for a in {0..100000}; do
if [[ $a =~ 0 ]] || [[ $a =~ 2 ]]; then
((s++));
fi
done
echo $s
s=0;
# 回数は多いです
time for a in {0..100000}; do
if [[ $a =~ 0 || $a =~ 2 ]]; then
((s++));
fi
done
echo $s
s=0;
# 回数は多いです
time for a in {0..100000}; do
if [[ $a =~ 0|2 ]]; then
((s++));
fi
done
echo $s
view raw b.sh hosted with ❤ by GitHub
real 0m0.686s
user 0m0.677s
sys 0m0.007s
1
real 0m0.367s
user 0m0.363s
sys 0m0.003s
1
real 0m0.608s
user 0m0.327s
sys 0m0.113s
11
real 0m0.001s
user 0m0.003s
sys 0m0.000s
11
real 0m1.299s
user 0m1.277s
sys 0m0.017s
62553
real 0m1.189s
user 0m1.163s
sys 0m0.023s
62553
real 0m1.028s
user 0m1.007s
sys 0m0.017s
62553
view raw b.log hosted with ❤ by GitHub

 最後に変数展開時の文字列操作があります。変数に対してsedとかを使いたくなったらこっちを使えないか考えるといいと思います。幾つか種類があるので詳細はmanやinfoを参照してください

#!/bin/bash
a="a.out"
time for i in {0..10000}; do
echo $a;
done | uniq
time for i in {0..100}; do
echo ${a%.*};
done | uniq
time for i in {0..100}; do
echo $a | sed 's/\..*//';
done | uniq
time for i in {0..100}; do
echo ${a/out/c};
done | uniq
time for i in {0..100}; do
echo $a | sed 's/out$/c/';
done | uniq
view raw c.sh hosted with ❤ by GitHub
a.out
real 0m0.113s
user 0m0.130s
sys 0m0.023s
a
real 0m0.002s
user 0m0.000s
sys 0m0.000s
a
real 0m0.475s
user 0m0.290s
sys 0m0.070s
a.c
real 0m0.003s
user 0m0.000s
sys 0m0.000s
a.c
real 0m0.478s
user 0m0.307s
sys 0m0.060s
view raw c.log hosted with ❤ by GitHub

pipeで繋げられるものは積極的に繋ぐ

Shellスクリプトでコマンドをpipeでつなげると、マルチプロセスで前の処理がすべて終わるのを待たずに次の処理を行うことになるので大抵速いです。特にこのご時世マルチコアなので本当に並列化ができて速いです。良い時代になりました。

専用コマンドがあったらそれを使う

専用コマンドがあったらそれを使うというのはシンプルですが有効な方法です。Shellスクリプトで使うコマンドは基本的にCで実装されているものが多く、まあ速いです。機能が少ない(用途に特化している)物のほうが大抵速いので専用のコマンドがあればそっちを使ったほうがいいです。例えば高機能なcutとしてawkを使うことはあると思いますが、cutで足りる場合にはcutの方が速かったりします。awkが悪者みたいですがshellでloopの処理があったときにawkに置き換えて高速化できることは良くあります。

while readはawkで書きなおしたほうが大体速い(こんなこと書くと怒られうる)

あんまり滅多なこと書くとShell界隈のいろんな人に怒られますが、while readはawkで書きなおしたほうが簡潔にかけて速いことが多いと思っているので僕はawkを使います。僕は基本的にawkが好きな人です。awkはShellスクリプトではない派の人もいるので気をつけましょう。while内部の変数は扱いに注意が必要なので僕はawkを推します。


$ time seq 100000 | awk '$0=(t=$1+t)' | tail -n 1
5000050000

real 0m0.102s
user 0m0.103s
sys 0m0.007s

$ time seq 100000 | while read a ;do echo $((t+=$a));done | tail -n 1
5000050000

real 0m1.712s
user 0m1.490s
sys 0m0.557s

11日目はzayarwinttunさんです。

1 件のコメント:

  1. シェルの while read が遅いのは、read コマンドが 1バイトずつ read(2) するのが大きな要因です。大量なデータ相手には辛い。

    返信削除