Swift のコード実装差によるコストを time で計測する

先日、次のような記事を投稿しました。今回は、その記事の続きです。計測環境を少しまじめに設定しました。

https://qiita.com/mitsuharu_e/items/b21d58612b12461c3a53

## 本記事の計測について

:::note warn
本記事で扱う計測は、厳密な計測ではないことに注意してください。実装の差によって「負荷がこれぐらい違うよ」程度の目安として、読んでください。厳密な計測は、厳密な計測結果を知りたいと思ったアナタが計測します。
:::

計測対象は macOS 上のターミナルで実行するシンプルな実行ファイルとします。その実行ファイルを実行して、計算時間、CPU使用率、メモリ使用量を計測します。

計測環境は、MacBook Pro 14インチ 2021 / Apple M1 Pro / メモリ 32 GB / macOS Sonoma 14.6.1 で行いました。計測ツールは前回と異なり [GNU Time](https://www.gnu.org/software/time/)、対象コードのビルドに swiftc （最適化オプションあり・なし）を利用しました。

```shell
% python --version
Python 3.11.2

% swiftc --version
swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0
```

## GNU Time で計測関数を作る

前回は Python で計測用のコードを作って計測しましたが、今回は time コマンドを利用します。計測用のコマンドなので、精度は期待できます。注意点として、macOS には２つの time が存在します（それぞれ何の time なのか分かっていないので、有識者の方々教えてください）。試しに私の環境にインストールされている Node.js のバージョン確認を計測してみました。

```bash
% time node -v         
v22.11.0
node -v  0.04s user 0.02s system 42% cpu 0.123 total
% /usr/bin/time node -v
v22.11.0
        0.11 real         0.03 user         0.01 sys
```

しかし、これらは計測したいデータが取得できないので、さらに第三の time となる [GNU Time](https://www.gnu.org/software/time/) を利用しました。homebrew からインストールできます。

```bash
brew install gnu-time
```

実行すると、時間の他に、CPU 利用率とメモリ利用量なども取得できます。

```bash
% gtime node -v    
v22.11.0
0.03user 0.02system 0:00.15elapsed 37%CPU (0avgtext+0avgdata 19264maxresident)k
0inputs+0outputs (1006major+1689minor)pagefaults 0swaps
```

一回だけの計測だとノイズも含まれるので、この計測を複数回実行して、その平均を計算するシェルスクリプトを作りました。

```bash:measure.sh
#!/bin/bash

# デフォルト値
EXECUTABLE=""
RUN_COUNT=1
OUTPUT_FILE="output.csv"
OUTPUT_DIR="dist"

# 使用方法
usage() {
  echo "Usage: $0 -e <executable> [-c <run_count>] [-o <output_file>]"
  echo "  -e: 実行するファイルのパス (必須)"
  echo "  -c: 実行回数 (デフォルト: $RUN_COUNT)"
  echo "  -o: 出力CSVファイル名 (デフォルト: output.csv)"
  exit 1
}

# gtime のコマンドが存在するか確認
if ! command -v gtime &> /dev/null; then
  echo "gtime コマンドが見つかりません。GNU time が必要です。"
  echo "Homebrew で gnu-time をインストールしてください。"
  echo "brew install gnu-time"
  exit 1
fi

# 引数の解析
while getopts "e:c:o:" opt; do
  case $opt in
    e) EXECUTABLE="$OPTARG" ;;
    c) RUN_COUNT="$OPTARG" ;;
    o) OUTPUT_FILE="$OPTARG" ;;
    *) usage ;;
  esac
done

# 実行ファイルが指定されていない場合はエラー
if [[ -z "$EXECUTABLE" ]]; then
  echo "Error: 実行ファイルが指定されていません"
  usage
fi

# 値を保存する配列を初期化
real_time_list=()
user_time_list=()
sys_time_list=()
memory_list=()
cpu_usage_list=()

if [ ! -d "$OUTPUT_DIR" ]; then
  mkdir $OUTPUT_DIR
fi

# CSVのヘッダーを書き込み
echo "# Elapsed Time(sec),System Time(sec),User Time(sec),CPU Usage(%),MaxMemory(MB)" > "$OUTPUT_DIR/$OUTPUT_FILE"

# 指定回数実行
for (( i=1; i<=RUN_COUNT; i++ )); do
  # /usr/bin/timeで実行し、出力を解析
  OUTPUT=$(gtime -f "%e %U %S %M %P" "$EXECUTABLE" 2>&1 >/dev/null)

  # 出力結果を分割して取得
  REAL_TIME=$(echo "$OUTPUT" | awk '{print $1}')
  USER_TIME=$(echo "$OUTPUT" | awk '{print $2}')
  SYS_TIME=$(echo "$OUTPUT" | awk '{print $3}')
  MEMORY_RAW=$(echo "$OUTPUT" | awk '{print $4}')
  CPU_USAGE_RAW=$(echo "$OUTPUT" | awk '{print $5}')

  # メモリを MB 単位に変換する
  MEMORY_MB=$(echo "scale=2; $MEMORY_RAW / 1024" | bc)

  # CPU_USAGE_RAWから単位（%）を取り除く
  CPU_USAGE=$(echo "$CPU_USAGE_RAW" | sed 's/%//')

  # 結果を配列に格納
  real_time_list+=("$REAL_TIME")
  user_time_list+=("$USER_TIME")
  sys_time_list+=("$SYS_TIME")
  memory_list+=("$MEMORY_MB")
  cpu_usage_list+=("$CPU_USAGE")

  # 結果をCSVに追加
  echo "$REAL_TIME,$USER_TIME,$SYS_TIME,$CPU_USAGE,$MEMORY_MB" >> "$OUTPUT_DIR/$OUTPUT_FILE"
done

echo "結果が $OUTPUT_FILE に保存されました。"

# 平均を計算する関数
calculate_mean() {
  local values=("$@")  # 配列引数
  local sum=0
  local count=${#values[@]}
  
  # 平均を計算
  for v in "${values[@]}"; do
    sum=$(echo "$sum + $v" | bc)
  done
  local mean=$(echo "scale=2; $sum / $count" | bc)

  # 結果を返す
  printf "%.2f" "$mean"
}

# 各値の平均を計算
real_mean=$(calculate_mean "${real_time_list[@]}")
user_mean=$(calculate_mean "${user_time_list[@]}")
sys_mean=$(calculate_mean "${sys_time_list[@]}")
memory_mean=$(calculate_mean "${memory_list[@]}")
cpu_mean=$(calculate_mean "${cpu_usage_list[@]}")

# 結果を表示
echo "==== 平均結果 ===="
echo "経過時間 (REAL_TIME): 平均 $real_mean 秒"
echo "ユーザーモード時間 (USER_TIME): 平均 $user_mean 秒"
echo "システムモード時間 (SYS_TIME): 平均 $sys_mean 秒"
echo "メモリ使用量 (MEMORY): 平均 $memory_mean MB"
echo "CPU利用率 (CPU_USAGE): 平均 $cpu_mean %"
```

このスクリプトは、次のように実行します。

```bash
./measure.sh -e {実行ファイル} -c {試行回数} -o {試行結果を書き出すcsvの名前}
```

たとえば、実行例は次のようになります。

```bash
% ./measure.sh -e ./code -c 10
結果が output.csv に保存されました。
==== 平均結果 ====
経過時間 (REAL_TIME): 平均 1.75 秒
ユーザーモード時間 (USER_TIME): 平均 1.73 秒
システムモード時間 (SYS_TIME): 平均 0.01 秒
メモリ使用量 (MEMORY): 平均 67.01 MB
CPU利用率 (CPU_USAGE): 平均 99.00 %
```

## DateFormatter のコストを計測する

前回と同様に DateFormatter の生成コストを計測します。

```swift:code1.swift
import Foundation

for _ in 0..<1_000_000 {
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
    _ = dateFormatter.string(from: Date())
}
```

一方で、`code2.swift` は１つの DateFormatter を利用します。

```swift:code2.swift
import Foundation

let dateFormatter = DateFormatter()
for _ in 0..<1_000_000 {
    dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
    _ = dateFormatter.string(from: Date())
}
```

これらコードは次のように最適化あり・なしの二種類でビルドして、計測関数に渡しました。

```shell
# 最適化なし
% swiftc -Onone code1.swift -o code1

# 最適化あり
% swiftc -O code1.swift -o code1_opt
```

## 結果

試行回数を 10 回として、計測したコストの平均値を取得しました。ここで、CPU 使用率は次の式で計算されています。

```math
{\rm CPU}使用率 = \frac{(ユーザー時間+システム時間)}{経過時間}
```

||計算時間|CPU使用率|メモリ使用量|
|:--|--:|--:|--:|
|code1（最適化なし）| 42.93 秒 | 99.00 % | 150.15 MB |
|code1（最適化あり）| 41.60 秒 | 99.00 % | 148.34 MB |
|code2（最適化なし）| 2.29 秒 | 99.00 % | 67.89 MB |
|code2（最適化あり）| 1.74 秒 | 99.00 % | 66.96 MB |

前回と同様に、DateFormatter の生成コストは高いですね。また、最適化オプションは、今回の恩恵は少ないですが、時間やメモリの削減に効果がありました。

## まとめ

計測は興味深いですね。計測スクリプトに CSV 出力をつけたので、次に機会があれば、グラフ化して結果を視覚化するのも面白そうですね。


今回および前回作成したの対象と計測コードを GitHub に公開しました。ご自身でも計測したい方はご利用ください。

https://github.com/mitsuharu/MeasureScripts