2
2

More than 3 years have passed since last update.

zshで大量のデータを扱うときの大幅なパフォーマンス低下について

Posted at

別の記事を書いていて zsh だけ謎な挙動を示したので先にそっちの原因を追求しないと話にならんとなったので。zsh のバージョンは 5.7.1、物理マシン上の Ubuntu 19.10 で検証しています。

やっていることは一行 100 バイト × 10万行(約 10 MB)のファイルを一行ごとに読み込んでどれくらい時間がかかるか?です。

パターン1

readformapfile それぞれを使った場合、どれくらい時間がかかるかを計測してみます。処理内容は行数を数えるだけです。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 は、まあ想定の範囲内と言えますが、formapfileread よりも遅くなっています。特に 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 (whilefor に置き換えた場合)

一旦大きな位置パラメータ $@ を作ってしまうと 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 は一行ごと読み込むので全体を読み込むformapfile より少メモリです
2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2