Quantcast
Channel: Bashタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 2914

シェルスクリプトでファイルを一行ずつreadする処理を高速化するテクニック

$
0
0

期待してる人ががっかりしないように、最初に結論を書いておくと dash、busybox (ash)、mksh、yash、zshにおいて読み込み速度は大幅に速くなりますが bash においては変わりません。ただしは使用メモリが減ります。kshは少し速くなりますが逆に使用メモリは大幅に増えます。シェルスクリプトでの読み込み速度を上げるもので、他の言語を使ったりコマンドを呼び出すよりも速くなるわけではありません。

シェルスクリプトでファイルを一行ごとに読み込む時、一般に使われているのは read関数です。ですが、この read関数、遅いというわさを聞きました。たしかに一行毎ずつ読み込むのですから catを使ってファイル全体を読み込むのに比べれば遅いでしょうが、そのままでは一行ずつ処理をすることは出来ません。

さて一行ずつ処理したい場合に使えるのは read以外どのような方法があるでしょうか?一つは、forを使います。catでファイル全体を読み取った後、IFSを使って改行区切りで位置パラメータ $@setします。ただしこの方法は改行が複数連続する場合は一つとみなされるので厳密には異なります。ですが改行のみの行は無視したいという要件は比較的あると思うので使えないということにはならないでしょう。

もう一つは、bash 専用の readarrayとzsh専用の mapfileを使います。readarraymapfileという別名でも使えるのですが、ややこしいのでこの記事では bash 用を readarray、zsh 用を mapfileと呼びます。

さて、まずは計測です。一行100文字×10万行(およそ10MB)のファイルを処理します。メモリ使用量はスクリプトの最後で ps axuして RSS の値を書いているだけです。計測対象のシェルは面倒なので dash, bash, ksh, zsh の4つに絞ります。

use_cat(){# 参考data=$(cat ./data.txt)}

use_read(){while IFS=read-r line;do i=$((i+1));done<./data.txt
}

use_for(){["$ZSH_VERSION"]&& setopt shwordsplit
  data=$(cat ./data.txt)IFS=$(printf'\n_')&&IFS=${IFS%_}for line in$data;do i=$((i+1));done}

for_loop(){for line in$data;do i=$((i+1));done;}
use_for_loop(){["$ZSH_VERSION"]&& setopt shwordsplit
  data=$(cat ./data.txt)IFS=$(printf'\n_')&&IFS=${IFS%_}set--$data
  for_loop "$@"}

use_readarray(){
  readarray -t lines < ./data.txt
  for line in"${lines[@]}";do i=$((i+1));done}

use_mapfile(){
  zmodload zsh/mapfile
  lines=("${(f)mapfile[./data.txt]}")for line in"${lines[@]}";do i=$((i+1));done}

mapfile_loop(){for line in"${lines[@]}";do i=$((i+1));done;}
use_mapfile(){
  zmodload zsh/mapfile
  lines=("${(f)mapfile[./data.txt]}")
  mapfile_loop
}
dashbashkshzsh
cat (参考)0.083s : 11,656 KB0.127s : 22,796 KB0.046s : 23,492 KB0.103s : 13,676 KB
read5.173s : 800 KB0.979s : 3,344 KB0.488s : 3,876 KB5.853s : 4,088 KB
for0.198s : 11,600 KB0.552s : 40,072 KB0.314s : 27,908 KB0.903s : 14,204 KB
for + loop0.359s : 46,844 KB1.065s : 155,688 KB0.640s : 66,980 KB1.104s : 35,756 KB
readarray-0.812s : 72,068 KB--
mapfile---0.425s : 15,836 KB
mapfile + loop---0.431s : 15,708 KB

やはり readは遅いですね・・・と思いましたが bashkshは結構善戦しているようです。(※ bash 2.03 では8秒ぐらいかかりましたが 2.05a 以降は大差ありません)readarraymapfileは特定のシェルに専用の機能であるいう点が問題なければ速いです。

メモリ使用量に関しては当然といえば当然ですが、一行のみ読み込む readが圧倒的に少なく、その他はファイルすべてを読み込んでいるためメモリ使用量は多くなります。

for + loop, mapfile + loopに関しては次の項目で説明します。今はそういう書き方をしてもさほど変わらないと思っていて下さい。

参考ですが、他のコマンドを使用した場合はこのとおりです。

time
wc -l0.006s
awk 'BEGIN{i=0}{i++} END{print i}' ./data.txt0.014s
sed -n '' ./data.txt0.015s
sed -n 's/^/_/g' ./data.txt0.021s

圧倒的ですね。他のコマンドで処理が完結できる場合はその方がいいでしょう。

ループの中の処理を関数化する

さて、この処理を改善する方法はないでしょうか?・・・の前に、先程のコードはループの中で直接カウントをしていました。コードの可読性、テスト容易性を上げるために、一行を処理する関数にしたいというのはよくある話だと思います。ということで先程のコードを次のようにカウントする関数を呼び出すように変更してみます。

count(){i=$((i+1));}

use_read(){while IFS=read-r line;do count "$line";done<./data.txt
}

use_for(){["$ZSH_VERSION"]&& setopt shwordsplit
  data=$(cat ./data.txt)IFS=$(printf'\n_')&&IFS=${IFS%_}for line in$data;do count "$line";done}

for_loop(){for line in"$@";do count "$line";done;}
use_for_loop(){["$ZSH_VERSION"]&& setopt shwordsplit
  data=$(cat ./data.txt)IFS=$(printf'\n_')&&IFS=${IFS%_}set--$data
  for_loop "$@"}

use_readarray(){
  readarray -t lines < ./data.txt
  for line in"${lines[@]}";do count "$line";done}

use_mapfile(){
  zmodload zsh/mapfile
  lines=("${(f)mapfile[./data.txt]}")for line in"${lines[@]}";do count "$line";done}

mapfile_loop(){for line in"${lines[@]}";do count "$line";done;}
use_mapfile(){
  zmodload zsh/mapfile
  lines=("${(f)mapfile[./data.txt]}")
  mapfile_loop
}
dashbashkshzsh
read5.173s : 800 KB0.979s : 3,344 KB0.488s : 3,876 KB5.853s : 4,088 KB
read + count5.211s : 2,600 KB1.580s : 10,500 KB0.909s : 3,996 KB7.246s : 3,980 KB
for0.198s : 11,600 KB0.552s : 40,072 KB0.314s : 27,908 KB0.903s : 14,204 KB
for + loop0.359s : 46,844 KB1.065s : 155,688 KB0.640s : 66,980 KB1.104s : 35,756 KB
for + count0.261s : 11,668 KB1.140s : 39,948 KB0.709s : 28,308 KB87.674s : 22,100 KB
for + loop + count0.425s : 46,868 KB1.627s : 155,760 KB1.013s : 75,276 KB5.585s : 35,704 KB
readarray-0.812s : 72,068 KB--
readarray + count-1.381s : 79,316 KB--
mapfile---0.425s : 15,836 KB
mapfile + loop---0.431s : 15,708 KB
mapfile + count---9.003s : 15,704 KB
mapfile + loop + count---2.913s : 23,696 KB

関数呼び出しのオーバーヘッドがあるため、どれもわずかに処理時間が伸びてますね。・・・わずか?はい、おかしいところが二箇所あります。それは zsh の formapfileです。関数呼び出しのオーバーヘッドとは思えないほど時間が伸びています。しかし、for + loop, mapfile + loopのように関数を一つ間に入れるだけでその処理時間は大きく下がります。理由はかなり推測ですが、別記事にまとめました。

改善方法

さてこれを改善する方法はないでしょうか?実行速度もですがメモリ使用量も改善したいところです。ここでよくシェルスクリプトに awk のコードを埋め込んで awk で処理を実装するテクニックが紹介されるのですが、そうすると二つの言語を使うことになります。また別のプロセスですから、awk とシェルスクリプトの関数や変数は相互にアクセスすることが出来ません。処理速度だけを見ると awk が良いとは思いますが開発が面倒になります。

速度も速くメモリも使用せず、シェルスクリプトで処理を行う方法。実はあります。

count(){i=$((i+1));}

use_callback1(){eval"$(sed"s/'/'\"'\"'/g; s/^/count '/; s/$/'/" ./data.txt)"}

use_callback2(){sed"s/'/'\"'\"'/g; s/^/count '/; s/$/'/" ./data.txt > ./data.sh
  . ./data.sh
  rm ./data.sh
}
dashbashkshzsh
callback1 + count0.266s : 2,600 KB1.685s : 46,588 KB0.829s : 71,456 KB63.002s : 4,016 KB
callback2 + count0.243s : 724 KB1.526s : 3,572 KB0.685s : 52,460 KB1.692s : 3,900 KB
read5.173s : 800 KB0.979s : 3,344 KB0.488s : 3,876 KB5.853s : 4,088 KB
read + count5.211s : 2,600 KB1.580s : 10,500 KB0.909s : 3,996 KB7.246s : 3,980 KB
for0.198s : 11,600 KB0.552s : 40,072 KB0.314s : 27,908 KB0.903s : 14,204 KB
for + count0.261s : 11,668 KB1.140s : 39,948 KB0.709s : 28,308 KB87.674s : 22,100 KB
for + loop + count0.425s : 46,868 KB1.627s : 155,760 KB1.013s : 75,276 KB5.585s : 35,704 KB
readarray-0.812s : 72,068 KB--
readarray + count-1.381s : 79,316 KB--
mapfile---0.425s : 15,836 KB
mapfile+loop + count---2.913s : 23,696 KB

仕組みとしては、データを sedを使用して、一行を引数として count関数を呼び出すシェルスクリプト(count '一行のデータ')に変換します。(シングルクォートはエスケープしています。)この変換にかかる時間は、0.05s ほどなので無視できるレベルです。そして変換されたシェルスクリプトを実行します。知っている人なら JSONP と似たような仕組みといえば理解しやすいと思います。read関数の読み込みの遅さをシェルのソースコード読み込み速度に置き換えているわけです。sedを使用してはいますが、メインの処理を sedでやるわけではなく単に関数呼び出しのシェルスクリプトに変換してるだけで汎用的に使えます。

改善されている項目について見ていきます。まず dash と zsh において大きく速度が改善されています。zsh は巨大な文字列の evalが苦手のようで一旦別のファイルに出力してから .コマンドで読み取ったほうが速い(callback1より callback2の方が速い)です。ksh はメモリ使用量が多いので少し残念ですね。コードがそのままメモリに残っているように思えます。

全てにおいて最速という訳ではありませんがバランスに優れています。また忘れてはいけないもう一つのメリットは、この方法は readと同じようにデータを一行処理できるところです。データが全て揃うまで待つ必要はなく非同期で動作させることが出来ます。

callback1evalを使用するため、全てをメモリに読み込みますが、以下のように FIFO ファイルを使うことで、データ生成 (+ sed) と .によるデータ読み込みを並列化することができます。また、シェルは echo 'echo test' | shのように標準入力からソースコードを入力することができるので、データを生成するプロセスとデータを処理するプロセスを分けることができるなら、この方法でも同じように並列化が可能です。

FIFO を使用した並列化

use_callback3(){mkfifo ./fifo
  sed"s/'/'\"'\"'/g; s/^/count '/; s/$/'/" ./data.txt > ./fifo &
  . ./fifo
  rm ./fifo
  wait$!}

また、ついでに evalによるパフォーマンス低下がどれくらいかも計測してみます。

use_callback4(){mkfifo ./fifo
  sed"s/'/'\"'\"'/g; s/^/eval count '/; s/$/'/" ./data.txt > ./fifo &
  . ./fifo
  rm ./fifo
  wait$!}

最後なので他のシェルの計測結果も加えます。

dashbashbusybox ashkshmkshyashzsh
read + count5.211s : 2,600 KB1.580s : 10,500 KB9.151s : 4 KB0.909s : 3,996 KB5.467s : 932 KB12.129s : 1,248 KB7.246s : 3,980 KB
callback1 + count0.266s : 2,600 KB1.685s : 46,588 KB0.936s : 1,660 KB0.829s : 71,456 KB0.599s : 1,812 KB1.371s : 28,188 KB63.002s : 4,016 KB
callback2 + count0.243s : 724 KB1.526s : 3,572 KB0.929s : 1,572 KB0.685s : 52,460 KB0.636s : 1,944 KB1.138s : 2,900 KB1.692s : 3,900 KB
callback3 + count0.195s : 736 KB1.562s : 3,332 KB0.484s : 1,636 KB0.646s : 52,360 KB0.552s : 2,756 KB1.106s : 2,828 KB1.777s : 4,120 KB
callback4 + count0.464s : 2,600 KB3.345s : 3,512 KB0.896s : 1,640 KB1.376s : 56,624 KB1.309s : 1,840 KB2.464s : 2,948 KB2.383s : 4,104 KB

busybox の結果が特徴的ですね。少メモリの組み込みデバイスに特化していると思われます。callback3callback4の差から evalによって倍ぐらい時間がかかるようですが、10万回でこの程度なので私はあまり気にせず使っています。

さいごに

ということで read処理を高速化するテクニックでした。まあそもそもシェルスクリプトでこんな大量のデータを扱う人もいないとは思いますが。実装は簡単なので気が向いたらライブラリ化するかもしれません。


Viewing all articles
Browse latest Browse all 2914

Trending Articles