# 【ICPC国内予選】テストケース並列化でなぐろう ICPC 国内予選は今年からルールが大きく変わり,事前の電子的準備が可能になり,提出までに 6 分の時間制限が追加されました. ## 並列化の威力 [AOJ への移植](https://onlinejudge.u-aizu.ac.jp/challenges/sources/ICPC/Prelim) を見ればわかる通り,ICPC 国内予選の問題は想定解が 8 秒 (定数倍を考えると 3 秒程度が妥当か) 以内に実行が終わるように設計されていると考えられます. しかし,実際の競技では時間制限は **6 分** で,その他の操作を取り除くと,5 分半 (330 秒) 程度をプログラムの実行に使うことができます. $\Theta(n^2)$ の計算量を $\Theta(n \log n)$ や $\Theta(n)$ に落とすような問題を考えてみましょう. 入力長はそこまで大きくしたくないので,$n = 10^6$ 程度が妥当でしょう. 1 秒間にできる "計算" の回数を $10^9$ 回として,$n^2$ 回の "計算" が必要な愚直解で計算すると,$10^{12} / 10^9 = 10^3$ 秒となり間に合いません. しかし,マルチスレッド等の並列化により $10$ 並列で計算できるとすると,実行時間は $100$ 秒となり間に合います! 競プロでよく言われる $10^9$ くらいのボーダーは,ICPC 国内予選では並列化をすると $10^{12}$ くらいになってしまうわけです. (一方,想定解は $10^9$ くらいのボーダーで作られていて,$10^3$ 倍に耐えるような問題を作るのは大変…, AtCoder でも $10$ 倍はないのに…) ## OpenMP 並列化といえばやはり OpenMP でしょうか. `#pragma omp parallel for` と書いて `-fopenmp` でコンパイルするだけで,for 文が並列に動きます.しかし,自分で並列化できるところを見つけて,その場に応じて並列化構文を書かないといけないので,面倒だし覚えることが多いです. もっと簡単に並列化できる部分として,テストケースの並列化がありますね. ## テストケース並列化 テストケースを並列化するには, 1. 入力を分解 2. 各テストケースごとに並列に答えを計算 3. 出力を結合 の処理が必要です.中でも,入力の分解が厄介ですね. できれば,入力を分解するためのコードを書かずに,普通に解くのと同様なコードで並列化したいです. ## マルチプロセス化 `std::thread` だとなぜか全然処理が進まなかったので,`fork()` を使ったマルチプロセス並列化をやります. - 普通に解くのとほとんど同じコードで並列化できる - RE や TLE をキャッチして,どのケースで落ちたかがわかる を目標にしましょう. ↓できたライブラリがこちらになります↓ https://gist.github.com/tatyam-prime/1161da013a31632690d616016d51d743 ## 技術解説 #### `fork()` https://manpages.ubuntu.com/manpages/noble/ja/man2/fork.2.html `fork()` は現在のプロセスを複製する関数です.メモリの状態やファイルディスクリプタ (現在開いているファイルなどの位置を表す構造体への参照) など,すべてが同じプロセスが 2 個できます. これらのプロセスは,`fork()` 関数の戻り値によって区別することができます.0 なら子プロセスで,正なら親プロセスです.このときの値は子のプロセス ID になっています. 以下のようにして使います. ```cpp= const pid_t p = fork(); if (p == -1) { perror("fork"); return 1; } if(p) { // 親プロセス } else { // 子プロセス } ``` このライブラリでは,テストケースごとに子プロセスを作って,子プロセスでテストケースの入力から出力までを行っています. `fork()` はファイルディスクリプタも複製し,ファイルディスクリプタは参照なので,`fork()` する前に親が開いていたファイルについて,**親子で読み込み・書き込み位置が共有されます.** 実行時に最初から開かれているファイルディスクリプタが 3 つあって, - 0 番 : 標準入力 - 1 番 : 標準出力 - 2 番 : 標準エラー出力 です.これらが `fork()` により子プロセスに複製されるので,**全プロセスで読み込み・書き込み位置が共有される**ようになっています. #### 共有メモリ 共有した標準入出力を使って入出力を行いたいので,いつ入出力して良いかを知るために親子間での通信が必要です. しかし,メモリは複製されていて共有はされないので,このままだとプロセス間通信ができません. プロセス間通信の第一の手法はパイプですが,親プロセスが (複製するので子プロセスも) テストケース数 × 4 のファイルディスクリプタを使用してしまい大変そうだったので,違う方法を使います. ChatGPT に聞いたところ,プロセス間でメモリを共有することができるようです. これを使って,現在のプロセスの状態を共有メモリに書き込むことによって,プロセス間通信して,いつ入出力して良いかを伝えることにします. 親プロセスは,以下の 2 つの処理を並行して行います. ##### 入力の処理 1. 生成された子プロセスは,標準入力から入力を行い,「入力が終了した」か「0 のみからなる行だった」を共有メモリに書き込みます. 2. 「0 のみからなる行だった」場合は,親プロセスは,子プロセスの生成を終了します. 3. 「入力が終了した」ら,親プロセスは,次の子プロセスを生成します. ##### 出力の処理 1. 子プロセスは,出力の準備ができたら,出力の許可が出るまで sleep します. 2. 子プロセスは,出力の許可が出たことを確認したら,標準出力に出力を行い,「出力が終了した」を共有メモリに書き込み,プロセスを終了します. 3. 親プロセスは,子プロセスの終了を確認したのち,「出力が終了した」であることを確認して,次のプロセスに出力の許可を出します. ## 使用例 --- <h3>連鎖行列積</h3> <div> <p>整数 <i>N</i> と整数列 <i>L</i><sub>0</sub>, <i>L</i><sub>1</sub>, …, <i>L</i><sub><i>N</i></sub> が与えられる.</p> <p><i>N</i> 個の行列 <i>A</i><sub>0</sub>, <i>A</i><sub>1</sub>, …, <i>A</i><sub><i>N</i>−1</sub> があり, 各 <i>i</i> = 0, 1, …, <i>N</i>−1 について,行列 <i>A</i><sub><i>i</i></sub> は縦 <i>L</i><sub><i>i</i></sub> 行,横 <i>L</i><sub><i>i</i>+1</sub> 列の行列である.</p> <p>これから,行列積 <i>A</i><sub>0</sub>・<i>A</i><sub>1</sub>・…・<i>A</i><sub><i>N</i>−1</sub> を計算したい.行列の積は 2 つの行列の間に定まる結合法則を満たす演算で,<i>X</i> 行 <i>Y</i> 列の行列 <i>A</i> と <i>Y</i> 行 <i>Z</i> 列の行列 <i>B</i> を掛けると,<i>X</i> 行 <i>Z</i> 列の行列 <i>A</i>・<i>B</i> が得られる.</p> <p>結合法則により,どの部分の積から先に計算しても正しい答えが得られるが,どの部分の積から先に計算するかによって,その計算量は異なる.</p> <p><i>X</i> 行 <i>Y</i> 列の行列と <i>Y</i> 行 <i>Z</i> 列の行列の積の計算に <i>X</i> × <i>Y</i> × <i>Z</i> のコストがかかるとき,行列積 <i>A</i><sub>0</sub>・<i>A</i><sub>1</sub>・…・<i>A</i><sub><i>N</i>−1</sub> を計算するためのコストの最小値を求めよ.</p> <p>より厳密には,以下の漸化式で定義される <i>f</i>(0, <i>N</i>) の値を求めよ.</p> <ul> <li>各 <i>i</i> = 0, 1, …, <i>N</i>−1 に対し,<i>f</i>(<i>i</i>, <i>i</i> + 1) = 0.</li> <li>0 &le; <i>i</i> &lt; <i>i</i> + 1 &lt; <i>j</i> &le; <i>N</i> を満たす整数の組 (<i>i</i>, <i>j</i>) に対し,<i>f</i>(<i>i</i>, <i>j</i>) = min<sub><i>k</i>∈{<i>i</i>+1,…,<i>j</i>–1}</sub> {<i>f</i>(<i>i</i>, <i>k</i>) + <i>f</i>(<i>k</i>, <i>j</i>) + <i>L</i><sub><i>i</i></sub> × <i>L</i><sub><i>k</i></sub> × <i>L</i><sub><i>j</i></sub>}.</li> </ul> </div> <h3>Input</h3> <div> <p>入力は複数のデータセットからなり,各データセットは次の形式で表される.データセットの個数は 100 を超えない.</p> <blockquote><i>N</i><br> <i>L</i><sub>0</sub> <i>L</i><sub>1</sub> … <i>L</i><sub><i>N</i></sub></blockquote> <p><i>N</i> は 行列の個数で,2 以上 3000 以下の整数である.<i>L</i><sub><i>i</i></sub> は行列 <i>A</i><sub><i>i</i></sub> の行数および行列 <i>A</i><sub><i>i</i>–1</sub> の列数を表す 1 以上 10000 以下の整数である.</p> <p>入力の終わりは 1 つのゼロからなる行で表される.すべてのデータセットにわたる <i>N</i> の総和は 30000 を超えない.</p> </div> <h3>Output</h3> <div> <p>各データセットについて,答えを 1 行に出力せよ.</p> </div> <h3>Sample Input</h3><div><pre>3 2 1 5 9 3 1 5 6 3 4 4 5 4 1 4 5 6 5 2 7 9 5 6 2260 2043 2528 3798 3052 4693 646 2 10000 10000 10000 0 </pre></div><h3>Output for the Sample Input</h3><div><pre>63 48 56 336 29262352960 1000000000000 </pre></div> --- $N = 3000$ が最大 $10$ ケースありますが,$N^3 / 6 \times 10 \approx 4.5 \times 10^{11}$ なので並列化で間に合うでしょう. まずは普通に解いてみましょう. ```cpp= #include <bits/stdc++.h> using namespace std; using ll = long long; void chmin(ll& a, ll b) { if(a > b) a = b; } int main() { while(1) { ll N; cin >> N; if(N == 0) break; vector<ll> L(N + 1); for(ll& x : L) cin >> x; vector dp(N + 1, vector<ll>(N + 1, LLONG_MAX)); for(ll i = 0; i < N; i++) dp[i][i + 1] = 0; for(ll i = N; i--; ) for(ll j = i; ++j <= N; ) for(ll k = j; ++k <= N; ) { chmin(dp[i][k], dp[i][j] + dp[j][k] + L[i] * L[j] * L[k]); } cout << dp[0][N] << endl; } } ``` 使用 CPU : M2 Mac ``` ./a.out < input.txt 25.44s user 0.12s system 97% cpu 26.176 total ``` 26 秒で終わってしまいました.$N = 7000$ くらいまで間に合う計算です. 続いて,ライブラリを使って解いてみましょう. テストケースの処理は `void solve()` に書き, - 入力の始めに `MP::begin_input();` を, - 入力の終わりに `MP::end_input();` を, - 出力の始めに `MP::begin_output();` を, - 出力の終わりに `MP::end_output();` を, - テストケースが終了していたら,`MP::end_of_file();` を書きます. ```cpp= #include "dataset_parallel_MP.cpp" #include <bits/stdc++.h> using namespace std; using ll = long long; void chmin(ll& a, ll b) { if(a > b) a = b; } void solve() { MP::begin_input(); ll N; cin >> N; if(N == 0) MP::end_of_file(); vector<ll> L(N + 1); for(ll& x : L) cin >> x; MP::end_input(); vector dp(N + 1, vector<ll>(N + 1, LLONG_MAX)); for(ll i = 0; i < N; i++) dp[i][i + 1] = 0; for(ll i = N; i--; ) for(ll j = i; ++j <= N; ) for(ll k = j; ++k <= N; ) { chmin(dp[i][k], dp[i][j] + dp[j][k] + L[i] * L[j] * L[k]); } MP::begin_output(); cout << dp[0][N] << endl; MP::end_output(); } ``` ``` ./a.out < input.txt 61.95s user 0.38s system 679% cpu 9.170 total ``` 9 秒で終了し,3 倍の高速化が実現されました:tada: ## おわり 今年の模擬国内予選は,並列化でオーダーを落とさず通せる問題がいくつかあったんですが,本番の方はあまり有用ではなさそうで,ちゃんと準備されているなあ… と思うなどしました.