[Bash] 並列数を制限しつつコマンドを並列実行するパターン

次のスクリプトは1回1秒かかるコマンド(関数)を単純に直列実行する例です。ループで10回実行するため、全て終わるまでに10秒かかります。

sleep_then_echo() {
  sleep 1
  echo $1
}

for i in $(seq 1 10); do
  sleep_then_echo $i
done

これを並列実行して速くする方法を考えてみます。

並列数を無制限にバックグラウンドジョブとして並列実行する場合

これを &wait を使って並列実行すれば、10並列で実行されるため約1秒で終わります。

sleep_then_echo() {
  sleep 1
  echo $1
}

for i in $(seq 1 10); do
  sleep_then_echo $i &
done

wait

時間のかかるコマンドの末尾に & を付けることでバックグラウンドジョブとして非同期に実行し、wait コマンドで実行中の全てのジョブが終了するまで待機しています。

並列数を制限しつつバックグラウンドジョブとして並列実行するパターン

&wait だけで手軽に並列実行の恩恵を受けられますが、もし並列実行する処理が重たいものだった場合、並列数を多くしすぎると実行負荷が上がり、逆に遅くなったり不安定になる場合があります。

こういった場合は次のようにして並列数を制限することで、安定して実行できるようになります。10回のループを5並列に実行するため、約2秒で終わります。

# 同時実行するジョブの最大数
MAX_CONCURRENT_JOBS=5

sleep_then_echo() {
  sleep 1
  echo $1
}

# 実行しているジョブ数を出力する
running_jobs_count() {
  # -r は running のジョブだけを出力するオプション
  jobs -r | wc -l
}

for i in $(seq 1 10); do
  # 実行しているジョブが最大数に達している場合は終了を待機する
  while (( $(running_jobs_count) >= MAX_CONCURRENT_JOBS )); do
    sleep 1
  done

  sleep_then_echo $i &
done

wait

ちなみに各ジョブの成功可否 (exit code) を判定したい場合は、もう少し書き方を工夫する必要があります。

xargs で並列数を制限しつつ並列実行するパターン

for を使わないパターンとして、xargs -P number でも手軽に並列実行ができます。-P オプションで並列実行するプロセス数を指定できます。次のスクリプトは10回のループを5並列に実行するため、約2秒で終わります。

sleep_then_echo() {
  sleep 1
  echo $1
}

# xargs で起動した子プロセスからも関数を使用出来るようにする
export -f sleep_then_echo

# sh -c ではなく bash -c としないと環境によって期待通りに動作しない点に注意
seq 1 10 | xargs -I@ -P5 -n1 bash -c "sleep_then_echo @"

また、こちらの方法は並列実行した各コマンドのいずれかが失敗した場合、xargs の exit code も1以上(失敗)になります。

そのため set -eo pipefail しておくだけでお手軽にエラーハンドリングができる点もメリットです。(参考: シェルスクリプトを高級言語のような書き味に近づける Tips 集

感想

for で並列実行するパターンは説明的なスクリプトを書きやすいところがメリットだと思います。

xargs を使うパターンはオプションで並列数を指定するだけなので簡潔かつエラー検知も容易ですが、xargs の挙動やオプションを理解して使う必要があります。

また GNU parallel を使う方法もありますが、こちらは別途インストールが必要なので割愛。

CakePHP + MySQL アプリのテスト時間を 72% 削減した話

この記事は CakePHP Advent Calendar 2018 の23日目の記事です。

CakePHP アプリケーションのユニットテストでボトルネックになりがちなのがデータベースへのディスク I/O 時間ですが、そういったプロジェクトでは FriendsOfCake/fixturize プラグイン を導入すると大幅に高速化できる可能性があります。

実例として、テストに18分も掛かっていた MySQL を使ったプロジェクトありましたが、導入後なんと5分にまで短縮されました。(約 72% の高速化🎉)

結果として早いサイクルで CI を回せるようになり開発スピードも上がりました。

何がボトルネックになっていたか?

テストケースごとに実行されるテストデータのリロードクエリがボトルネックになっていました。リロードクエリとは TRUNCATE TABLE クエリと INSERT クエリのセットのことで、積み重なると高コストなクエリになります。

テストデータのリロード時間はテストケースが増えるほど支配的なものになる

CakePHP のユニットテストは、テストデータを Fixture クラスとして定義しておき、テストクラスの側で使用したい Fixture 名の一覧を記述します。

例として、テストクラスは以下のようになります。

<?php
namespace App\Test\TestCase;
use Cake\TestSuite\TestCase;

class ArticlesTest extends TestCase
{
    // 3テーブル分のテストデータの使用を宣言
    public $fixtures = [
        'app.articles',
        'app.comments',
        'app.users',
    ];

    public function testCase1() {
        // テストコード
    }

    public function testCase2() {
        // テストコード
    }
}

このテストを実行すると testCase1()testCase2() のテストケースの実行前に、それぞれ3テーブル分のリロードクエリが実行されます。同じように、このクラスに100個のテストケースがあったとしたらリロードクエリは最低で300回実行されます。

極端な例を挙げましたが、リロード回数はテスト数とフィクスチャ数の掛け算で増えていくため、簡単に無視できない I/O コストになることは想像に難くないと思います。

毎回テストデータをリロードする理由

無意味に何度もリロードしている訳ではありません。毎回リロードすることで、テストが書きやすくなるというメリットがあります。

テスト中にいくらテストデータを書き換えたとしても、次のテストでは元の状態にリセットされているので、テストケース間でテストデータの状態について副作用を気にする必要がなくなります。

この高速化プラグインがすること

FriendsOfCake/fixturize のソースを見ると分かりますが、このプラグインのすることは非常にシンプルです。上述の「毎回同じテストデータの状態でテストを開始したい!」という要求を満たしつつ、必要なときだけリロードするようにし、リロードクエリの実行回数を削減します。

具体的には、初回のフィクスチャロード後に CHECKSUM TABLE クエリ でテーブルのチェックサム値を取っておき、次以降のリロードは差分があったときだけ行うようにします。

チェックサム値に差分がなければテストデータが変更されていないということなので、リロードもスキップして問題ないということになります。

プラグインで高速化できる可能性のあるプロジェクト

ということで、以下に該当するプロジェクトであればプラグインを導入するだけで大幅にテスト時間を削減できる可能性があります。

  • CakePHP 3.x 製アプリ
  • MySQL / MariaDB / Percona のいずれかを使用している
  • テストケース数とテーブル数が多いプロジェクト
  • 複数テーブルを参照する結合的なテストをしているプロジェクト

また、もしこのプラグインが使えない環境のプロジェクトであっても、不要なロードを減らすというアプローチ自体は有効かもしれません。

おまけ: その他のアプローチ

他のアドベントカレンダーになりますが、素晴らしい記事を見かけましたのでそちらも紹介しておきます。

DB ストレージを tmpfs というオンメモリのファイルシステムに差し替え、高コストなディスク IO を無くすることで高速化するというアプローチです。私も手元のプロジェクトで試してみましたが、実際に 40% ほど高速化できました。FriendsOfCake/fixturize プラグイン と併用することで更に高速化が見込めます。

MySQLの複合主キーテーブル検索時の速度比較

MySQL の複合主キーテーブルのレコードを何件かピックアップして主キー検索したい機会があったので、いくつかのパターンの SQL で検索してパフォーマンス比較をしてみました。

実験の内容

  • それぞれ適当にピックアップした15レコードを1クエリで検索する
  • 実行時間は同じクエリを5回実行したときの最小と最大の時間を計測
  • レコードは適当に100,000件投入

実験環境

  • Ubuntu 13.10 (on Vagrant)
  • MySQL 5.5.37
  • InnoDB

使用したテーブル

CREATE TABLE `myTable` (
  `player_id` int(11) NOT NULL,
  `id` int(11) NOT NULL,
  `body` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`player_id`,`id`)
) ENGINE=InnoDB;

各検索方法の実行時間とインデックス使用状況

1. OR検索の場合

SQL例

SELECT * FROM `myTable`
WHERE  (`player_id` = 50 AND `id` = 4856)
  OR (`player_id` = 61 AND `id` = 4274);

※実験時は15個のORを繋げて検索しましたが長くなるのでここでは省略しています。

実行時間

1.2ms ~ 1.5ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE myTable range PRIMARY PRIMARY 8 NULL 15 Using where

2. UNION ALL検索の場合

SQL例

SELECT * FROM `myTable` WHERE (`player_id` = 50 AND `id` = 4856)
UNION ALL
SELECT * FROM `myTable` WHERE (`player_id` = 61 AND `id` = 4274);

実行時間

1.5ms ~ 2.0ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 PRIMARY myTable const PRIMARY PRIMARY 8 const,const 1
2 UNION myTable const PRIMARY PRIMARY 8 const,const 1
15 UNION myTable const PRIMARY PRIMARY 8 const,const 1
NULL UNION RESULT ALL NULL NULL NULL NULL NULL

3. WHERE IN の複合カラム指定による検索の場合

SQL例

SELECT * FROM `myTable`
WHERE (player_id, id) IN ((50, 4856),(61, 4274));

実行時間

40.9ms ~ 49.7ms

EXPLAIN結果

id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE myTable ALL NULL NULL NULL NULL 100714 Using where

コメント

最も高速なのは普通に OR で繋げる検索でした。range スキャンになるんですね。次点で UNION ALL 検索。ちなみに ALL 無しの UNION に変えてもほぼ同じ速度でした。

そして最も遅かったのは WHERE IN 検索でした。IN 句に指定する件数が多いとオプティマイザはフルスキャンを選択するため大幅に遅い結果になりました。