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

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

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

### パターン1

`read`、`for`、`mapfile` それぞれを使った場合、どれくらい時間がかかるかを計測してみます。処理内容は行数を数えるだけです。`for` を使った場合は複数の連続した改行が一つとみなされるので厳密には違います。

```sh
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

次にループの中で行数を数えるのではなく関数を呼び出して数えます。関数呼び出すのオーバーヘッドがあるので少し遅くなると予想できます。

```sh
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

いろいろ試してみて、どうもスタックフレームに大量のデータを積み込む処理（？）のオーバーヘッドな気がしたので（※本当の所はわかりません）以下のように関数を一つ間に入れてみました。

```sh
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 はなんとなく関数呼び出しが遅い気がしていましたが、これが原因なのかもしれません。

### データがあるのが悪いんだな？

関数を一つ間に入れて改善したので、データが有るから遅くなってるんだなと仮説を立て以下のようなコードで検証してみました。

```sh
# 検証1 単純に10万回ループして10万回カウントする
count() {
  i=$((i+1))
}

use_for() {
  j=0 k=100000
  while [ $j -lt $k ] && j=$((j+1)); do
    count
  done
}
```

```sh
# 検証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
}
```

```sh
# 検証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` に置き換えた場合)

一旦大きな位置パラメータ `$@` を作ってしまうと `set --` でクリアしても消えないようです。

理屈がさっぱりわかりませんが zsh で巨大なデータを位置パラメータに入れて `for` でループする時は、ループ専用の関数に渡して間接的に実行しろということになるでしょうか。そもそもそんなに大量のデータを位置パラメータに入れるなって話でもありますが。

### 新展開

zsh の他のバージョンではどうかと思って、時間がかかりすぎるので調べる際データ数を減らした所、大幅に実行時間が減りました・・・。

```sh
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` より少メモリです