別の記事を書いていて zsh だけ謎な挙動を示したので先にそっちの原因を追求しないと話にならんとなったので。zsh のバージョンは 5.7.1、物理マシン上の Ubuntu 19.10 で検証しています。
やっていることは一行 100 バイト × 10万行(約 10 MB)のファイルを一行ごとに読み込んでどれくらい時間がかかるか?です。
パターン1
read
、for
、mapfile
それぞれを使った場合、どれくらい時間がかかるかを計測してみます。処理内容は行数を数えるだけです。for
を使った場合は複数の連続した改行が一つとみなされるので厳密には違います。
use_read() {
while IFS= read -r line; do
i=$((i+1))
done <./data.txt
}
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
for line in $data; do
i=$((i+1))
done
}
use_mapfile() {
zmodload zsh/mapfile
lines=("${(f)mapfile[./data.txt]}")
for line in "${lines[@]}"; do
i=$((i+1))
done
}
パターン1 | |
---|---|
read | 5.900s |
for | 0.898s |
mapfile | 0.422s |
mapfile
がなかなかいい速度を出していますね。それに対して read
は遅いようです。
パターン2
次にループの中で行数を数えるのではなく関数を呼び出して数えます。関数呼び出すのオーバーヘッドがあるので少し遅くなると予想できます。
count() { i=$((i+1)); }
use_read() {
while IFS= read -r line; do
count
done <./data.txt
}
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
for line in $data; do
count
done
}
use_mapfile() {
zmodload zsh/mapfile
lines=("${(f)mapfile[./data.txt]}")
for line in "${lines[@]}"; do
count
done
}
パターン1 | パターン2 | |
---|---|---|
read | 5.900s | 6.993s |
for | 0.898s | 1m30.030s |
mapfile | 0.422s | 12.086s |
read
は、まあ想定の範囲内と言えますが、for
と mapfile
が read
よりも遅くなっています。特に for
が極端に悪くなりました。これは一体・・・?
パターン3
いろいろ試してみて、どうもスタックフレームに大量のデータを積み込む処理(?)のオーバーヘッドな気がしたので(※本当の所はわかりません)以下のように関数を一つ間に入れてみました。
count() { i=$((i+1)); }
use_read() {
while IFS= read -r line; do
count
done <./data.txt
}
for_loop() {
for line in "$@"; do
count
done
}
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
set -- $data
for_loop "$@"
}
mapfile_loop() {
for line in "${lines[@]}"; do
count
done
}
use_mapfile() {
zmodload zsh/mapfile
lines=("${(f)mapfile[./data.txt]}")
mapfile_loop
}
パターン1 | パターン2 | パターン3 | |
---|---|---|---|
read | 5.900s | 6.993s | 6.993s (同左) |
for | 0.898s | 1m26.757s | 5.364s |
mapfile | 0.422s | 12.086s | 4.013s |
改善されて read
よりも速くなりました。zsh はなんとなく関数呼び出しが遅い気がしていましたが、これが原因なのかもしれません。
データがあるのが悪いんだな?
関数を一つ間に入れて改善したので、データが有るから遅くなってるんだなと仮説を立て以下のようなコードで検証してみました。
# 検証1 単純に10万回ループして10万回カウントする
count() {
i=$((i+1))
}
use_for() {
j=0 k=100000
while [ $j -lt $k ] && j=$((j+1)); do
count
done
}
# 検証2 一旦データをメモリに読み込むがクリアしてから
# 10万回ループして10万回カウントする
count() { i=$((i+1)); }
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
set -- $data
unset data # 変数をクリア
set -- # 位置パラメータ $@ をクリア
j=0 k=100000
while [ $j -lt $k ] && j=$((j+1)); do
count
done
}
# 検証3 データをメモリに読み込みクリアせずに関数を間に入れて
# 10万回ループして10万回カウントする
count() { i=$((i+1)); }
for_loop() {
j=0 k=100000
# for line in "$@" # 参考
while [ $j -lt $k ] && j=$((j+1)); do
count
done
}
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
set -- $data
for_loop "$@"
# for_loop $data # setせずに直接渡しても大差なし
}
検証1と検証2の処理に大差はないと予測しましたが・・・外れました。
- 検証1 1.626s
- 検証2 1m26.701s
- 検証3 2.620s
- 参考 5.697s (
while
をfor
に置き換えた場合)
- 参考 5.697s (
一旦大きな位置パラメータ $@
を作ってしまうと set --
でクリアしても消えないようです。
理屈がさっぱりわかりませんが zsh で巨大なデータを位置パラメータに入れて for
でループする時は、ループ専用の関数に渡して間接的に実行しろということになるでしょうか。そもそもそんなに大量のデータを位置パラメータに入れるなって話でもありますが。
新展開
zsh の他のバージョンではどうかと思って、時間がかかりすぎるので調べる際データ数を減らした所、大幅に実行時間が減りました・・・。
count() {
i=$((i+1))
}
use_for() {
setopt shwordsplit
data=$(cat ./data.txt)
IFS=$'\n'
set -- $data
j=0 k=$#
while [ $j -lt $k ] && j=$((j+1)); do
count
done
}
time | |
---|---|
10000 | 0.510s |
20000 | 2.071s |
30000 | 5.961s |
40000 | 11.829s |
50000 | 19.244s |
60000 | 28.077s |
明らかに指数関数的に増えてますね。まあ位置パラメータに関する何かを2回やってると考えれば当然かもしれません。配列に関しては、パターン2の結果から見ると多分比例関数的な形なのでしょうね
ちなみに10万件のデータで各バージョンでの違いはこんな感じでした。実査使ってみると確かに4系の方が速く感じるんですよね。5系で何があったんでしょうか?
バージョン | 秒 |
---|---|
3.1.9 | 55.88 |
4.0.4 | 38.99 |
4.2.5 | 37.51 |
4.3.2 | 40.34 |
4.3.6 | 34.03 |
4.3.10 | 39.10 |
4.3.17 | 23.93 |
5.0.7 | 229.43 |
5.1.1 | 241.99 |
5.3.1 | 80.16 |
5.4.2 | 89.64 |
5.5.1 | 87.55 |
5.7.1 | 84.70 |
5.8.3 | 87.23 |
まとめ
zsh で大きなファイルを扱う場合
- 一般的な
read
を使えば最速ではないがこんな罠にハマることはない -
for
でループする場合は位置パラメータの数が数万のレベルになると極端に遅くなっていくから注意 -
read
よりも zsh 専用のmapfile
の方が速い。ただし注意して使わないとこれもread
より遅くなる - 本記事内では触れてないですが
read
は一行ごと読み込むので全体を読み込むfor
やmapfile
より少メモリです