テストと計算結果の比較
MISO を変更したときに、壊していないことを確かめる方法をまとめます。
前半は MISO のユニットテスト(doctest と ctest)の実行と読み方、後半はデモの出力を比べる方法と、float の丸め誤差の目安です。
ビルドの手順は 第 1 章 を前提にします。
比較する対象
何を比べるかによって、期待する一致の程度が変わります。
比較 |
確かめる内容 |
期待する一致 |
|---|---|---|
部品の結果と、式や手計算から求めた期待値 |
添字、係数、境界の扱い |
整数なら完全に一致。実数なら丸め誤差程度 |
同じ実行ファイル・同じ設定での 2 回の実行 |
結果が実行ごとに変わらないこと |
ビット単位で同一 |
変更前と変更後(計算を変えないはずの変更) |
書き換えで計算が変わっていないこと |
ビット単位で同一、または丸め誤差程度 |
MPI の分割数・分割方向を変えた実行 |
領域分割と通信 |
全体の配列として比べてビット単位で同一、または丸め誤差程度 |
CPU 版と GPU 版、コンパイラや最適化の違い |
演算の順序や命令の違いの影響 |
丸め誤差程度(時間発展とともに増えます) |
数値解と厳密解 |
離散化(格子・時間刻み)の誤差 |
格子を細かくすると小さくなること |
MISO の PR では、数値結果への影響を「ビット単位で同一」「丸め誤差程度」「離散化誤差」「物理的に変わる」のどれかで書きます(MISO の dev/notes/rules_ai.md)。
この資料の方法は、そのどれに当たるかを判断する材料になります。
期待値は、確かめたい関数をそのまま使わずに、式や別の簡単な計算から求めます。 同じ関数で期待値を作ると、関数の誤りが期待値にも入り、比較で見つけられません。
ユニットテストを実行する
まとめて実行する(ctest)
MISO のユニットテストは miso/tests/ にあり、MISO 単独でビルドすると一緒に作られます(ENABLE_TESTING が既定で ON)。
ビルドフォルダは MISO のフォルダの外でも構いません。
cmake -S /path/to/MISO/miso -B build_miso -DMISO_USE_CUDA=OFF
cmake --build build_miso --parallel 4
cd build_miso
OMPI_MCA_btl_vader_single_copy_mechanism=none \
OMPI_MCA_rmaps_base_oversubscribe=yes \
ctest --output-on-failure
Start 1: test_array1d_cpp
1/15 Test #1: test_array1d_cpp ................. Passed 0.01 sec
...
15/15 Test #15: test_parallel_grid_cpp ........... Passed 2.08 sec
100% tests passed, 0 tests failed out of 15
テストの数と名前は MISO の版によって変わります。
--output-on-failure は、失敗したテストの出力だけを表示する指定です。成功したテストの出力は表示しません。
名前=値 コマンド の形で書いた環境変数は、そのコマンドの間だけ有効です。2 つの環境変数は Open MPI の動作を変えます。
環境変数 |
意味 |
|---|---|
|
同じ計算機の中のプロセス間通信で、コンテナなどでは使えないことがあるメモリコピーの仕組みを使わないようにします |
|
CPU のコア数より多いプロセスの起動を許します。2 プロセスのテストを、コアの少ない計算機でも動かせます |
手元の計算機で付けなくても動く場合がありますが、MISO の手順(dev/notes/usage_ai.md)と GitHub の CI はこの形で実行しています。
複数プロセスのテストは、さらに miso/shared/mpiexec_with_env.sh を通して起動され、miso/shared/openmpi_config.env の設定も読み込みます。
テストを選ぶ
コマンド |
行うこと |
|---|---|
|
実行せずに、登録されたテストの一覧を表示します |
|
名前に |
|
選んだテストを、実行コマンドと出力をすべて表示して実行します |
ctest に登録されているテストの名前は、ソースのファイル名に _cpp(GPU 版は _cu)を付けたものです。
miso/tests/serial/CMakeLists.txt と miso/tests/parallel/CMakeLists.txt は、test_*.cpp に当たるファイルを集めて自動で登録します。
新しいテストのファイルを serial/ に置けば、次の設定のときに登録されます。
テストの実行ファイルを直接動かす
各テストは独立した実行ファイルで、build_miso/tests/serial/ と build_miso/tests/parallel/ にあります。
直接動かすと、doctest のオプションで中身をさらに絞れます。
./tests/serial/test_execution_cpp --list-test-cases
./tests/serial/test_execution_cpp --test-case="Test reduce 1D CPU" --success
オプション |
意味 |
|---|---|
|
ファイルに含まれるテストケースの名前を表示します |
|
名前が一致するテストケースだけを実行します。 |
|
成功した確認も表示します |
|
文字色の制御文字を出しません。ログをファイルに残すときに使います |
parallel/ のテストは MPI の複数プロセスで動かします。ctest と同じ形で起動するには次のようにします。
/path/to/MISO/miso/shared/mpiexec_with_env.sh -n 2 ./tests/parallel/test_parallel_grid_cpp
各プロセスが自分の結果の要約を表示するので、要約が 2 回出ます。すべてのプロセスの要約を確かめます。
GPU 版のテスト(*.cu)は -DMISO_USE_CUDA=ON のときだけ作られます。第 9 章 を参照してください。
テストの読み方
MISO のユニットテストは doctest というテスト用のライブラリで書かれています。
doctest はヘッダ 1 つでできていて、ビルドの設定のときに miso/tests/CMakeLists.txt の FetchContent が GitHub から取得します。
miso/tests/serial/test_execution.cpp の最初のテストを読みます(抜粋)。
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
#include <miso/array1d.hpp>
#include <miso/array3d.hpp>
#include <miso/execution.hpp>
using namespace miso;
TEST_CASE("Test for_each 1D CPU" * doctest::test_suite("execution")) {
Range1D range{2, 4};
Array1D<int, backend::Host> arr(5);
auto view = arr.view();
for_each(backend::Host{}, range, [&](int i) { view[i] = i * i; });
// This also works, but users should use the view interface as API.
// for_each<backend::Host>(range, [&](int i) { arr[i] = i * i; });
for (int i = 2; i < 4; ++i)
REQUIRE(view[i] == i * i);
}
書き方 |
意味 |
|---|---|
|
doctest に |
|
テストケースを 1 つ定義します。 |
|
条件が成り立たなければ失敗とし、そのテストケースの残りを実行しません |
|
条件が成り立たなければ失敗として記録し、続きも実行します |
このテストは、添字 2 と 3 だけに i * i を入れる for_each を実行し、その 2 つの値を確かめています。
整数の計算なので、== でぴったり一致することを求めます。
for_each、Range1D、Array1D とラムダ式は 第 7 章 で説明します。
実数を比べる:doctest::Approx
実数の計算では、数学的に同じ式でも、計算の順序によって末尾の桁が変わります(後の節)。
そのため実数の比較には、== ではなく doctest::Approx を使って「十分近い」ことを確かめます。
次は、float で 0.1 を 10 回足した結果を、2 つの方法で確かめる例です。
doctest のヘッダは、MISO のテストをビルドしたフォルダの _deps/doctest-src/ にあります。
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>
TEST_CASE("Sum of 0.1f" * doctest::test_suite("example")) {
float s = 0.0f;
for (int i = 0; i < 10; ++i) {
s += 0.1f; // add 0.1 ten times
}
CHECK(s == doctest::Approx(1.0)); // passes if close to within about 1e-5 relative
CHECK(s == 1.0f); // passes only if bitwise identical
}
g++ -std=c++17 -I/path/to/build_miso/_deps/doctest-src test_sum.cpp -o test_sum
./test_sum --no-colors
test_sum.cpp:10: ERROR: CHECK( s == 1.0f ) is NOT correct!
values: CHECK( 1 == 1 )
===============================================================================
[doctest] test cases: 1 | 0 passed | 1 failed | 0 skipped
[doctest] assertions: 2 | 1 passed | 1 failed |
[doctest] Status: FAILURE!
Approx の確認は成功し、== の確認は失敗します。s は 1.00000012 で、1 とは最後の 1 桁だけ違います。
失敗の表示 values: CHECK( 1 == 1 ) では両辺が同じに見えますが、これは表示の桁数が少ないためです。
実数の比較が失敗したときは、自分で桁数を増やして値を表示して確かめます。
テストが 1 つでも失敗すると、実行ファイルは 0 以外の終了コードを返し、ctest はそのテストを Failed と表示します。
a == doctest::Approx(b) は、次の条件が成り立つときに成功します(doctest 2.4.12 の実装)。
\(\varepsilon\) は .epsilon(値) で、\(s\) は .scale(値) で指定します。
既定は \(\varepsilon\) が float の計算機イプシロンの 100 倍(約 \(1.2\times10^{-5}\))、\(s = 0\) で、相対誤差で比べることになります。
.scale(1.0) とすると、0 に近い値では \(\varepsilon\) を絶対誤差の許容値として比べます。
期待値が 0 の点で相対誤差を使うと、どんな小さな差も失敗になるためです。
MISO の統合テストの例
miso/tests/parallel/test_hd1d_shock_tube.cpp は、MISO で 1 次元の衝撃波管(Sod の問題)を実際に時間発展させるテストです。
2 種類の比較をしています(抜粋)。
CHECK(ro_diff_sum / ro_sum < 5.e-2);
CHECK(vx_diff_sum / vx_sum < 5.e-2);
constexpr double single_precision_epsilon = 1e-5;
for (std::size_t i = 0; i < model_x.grid.i_total; ++i) {
CHECK(model_x.mhd.qq.ro[i] == doctest::Approx(model_y.mhd.qq.ro[i])
.epsilon(single_precision_epsilon)
.scale(1.0));
1 つ目は厳密解との比較です。差の絶対値の和を値の和で割った量が 5% 未満であることを確かめます。 厳密解との差には離散化の誤差が含まれるので、許容値は大きめです。
2 つ目は、同じ問題を x・y・z の方向に置いた計算どうしの比較です。数学的には同じ計算なので、 丸め誤差程度(\(\varepsilon = 10^{-5}\))の一致を求めています。
デモの出力を比べる
MISO の変更が計算結果を変えていないかは、変更前と変更後でデモを実行し、出力を比べて確かめます。
比べるための実行
デモの出力先 io.save_dir(既定は data/)は、config.yaml のあるフォルダからの相対パスです。
そこで、実行ごとに別のフォルダを作って config.yaml を写し、--config でそのファイルを指定すると、出力を分けられます。
demo/mhd2d_vortex をビルド済みとして、2 回実行する例です。
mkdir -p "$HOME/miso-work/compare/run_a" "$HOME/miso-work/compare/run_b"
cd "$HOME/miso-work/compare"
cp /path/to/MISO/demo/mhd2d_vortex/config.yaml run_a/
cp /path/to/MISO/demo/mhd2d_vortex/config.yaml run_b/
set -a
source /path/to/MISO/demo/shared/openmpi_config.env
set +a
mpiexec -np 2 /path/to/MISO/demo/mhd2d_vortex/build/mhd2d_vortex --config=run_a/config.yaml
mpiexec -np 2 /path/to/MISO/demo/mhd2d_vortex/build/mhd2d_vortex --config=run_b/config.yaml
set -a から set +a までは、openmpi_config.env に書かれた環境変数を、このターミナルで有効にする操作です(app_run.sh も同じことをしています)。
デモの config.yaml は io.continue: true なので、出力先にすでに結果があると、その続きから計算します。
比べるための実行は、毎回空のフォルダで行います。
変更前と変更後を比べるときは、それぞれの MISO でビルドした実行ファイルを使います。 同じ MISO のフォルダで比べる場合は、変更前の実行ファイルを別の名前で写しておくか、ビルドフォルダを分けます。
ビット単位の比較(cmp, diff -rq)
2 つのファイルが 1 バイトも違わないかは cmp で確かめます。
cmp run_a/data/mhd/mhd.00000080.00000000.bin run_b/data/mhd/mhd.00000080.00000000.bin
同じなら何も表示せず、違えば最初に違うバイトの位置を表示します。フォルダ全体は diff -rq で比べられます。
diff -rq run_a/data run_b/data
何も表示されなければ、すべての出力がビット単位で同一です。
ビット単位の比較は、出力の並びが同じときだけ使えます。
MPI の分割数や分割方向を変えると、各ランクのファイル(mhd.<n_output>.<rank>.bin)が受け持つ範囲が変わるので、
中身の値が同じでもファイルは一致しません。その場合は、次のように全体の配列に組み立ててから比べます。
全体の配列で比べる(pymiso)
pymiso.Data は、各ランクのファイルを読んで全体の配列に組み立てます(ゴーストセルは除かれます)。
次のスクリプトは、2 つの出力の最後の出力番号どうしで、変数ごとに差の最大値を代表値(その変数の絶対値の最大)で割った量を表示します。
期待値が 0 になる点もあるので、点ごとの相対誤差ではなく、代表値で割っています。
import sys
import numpy as np
import pymiso
dir_a, dir_b = sys.argv[1], sys.argv[2]
a = pymiso.Data(data_dir=dir_a)
b = pymiso.Data(data_dir=dir_b)
n = min(a.n_output, b.n_output) # last output present in both
a.load(n)
b.load(n)
print(f"n_output = {n}, time = {a.time.time:.6g}, {b.time.time:.6g}")
for var in ["ro", "vx", "vy", "vz", "bx", "by", "bz", "ei"]:
qa = getattr(a, var).astype(np.float64)
qb = getattr(b, var).astype(np.float64)
scale = np.max(np.abs(qa)) # divide by a representative value (the maximum magnitude)
if scale == 0.0:
print(f"{var}: variable is 0 in both")
continue
err = np.max(np.abs(qa - qb)) / scale
print(f"{var}: max|a-b|/max|a| = {err:.3e}")
compare.py として保存し、第 1 章 で pymiso を入れた仮想環境で実行します。
python compare.py run_a/data run_b/data
a.time は時刻のファイルを扱うオブジェクトで、時刻の値は a.time.time です。
差は float64 に直してから計算します。float32 のまま引き算すると、差そのものにも丸めが入るためです。
vz と bz は 2 次元のこの問題ではすべて 0 なので、代表値で割れません。
比較の例
demo/mhd2d_vortex の既定の設定(128×128、時刻 0.8 まで 354 ステップ、Real は float)で、条件を変えて比べた結果です
(Ubuntu 24.04、GCC 13.3、Open MPI 4.1)。
比べたもの |
|
|
|---|---|---|
同じ実行ファイルで 2 回実行 |
差なし |
すべて 0 |
Release と Debug でビルドした実行ファイル |
差なし |
すべて 0 |
MPI の分割を x 方向 2 つから y 方向 2 つに変更 |
|
すべて 0 |
|
初期値(出力番号 0)以外の |
変数によって \(1.7\times10^{-6}\)〜\(5.7\times10^{-6}\) |
分割を変えた計算は、ファイルとしては違っても、全体の配列としてはビット単位で同一でした。
-march=native は、使っている CPU 固有の命令(積和演算をまとめる FMA など)をコンパイラに使わせる指定です。
演算の丸め方が変わるため、結果は丸め誤差程度だけ変わります。CFL 条件で決まる時間刻みも末尾の桁が変わり、出力の時刻が 7 桁目で違うものがありました。この比較での差の時間変化は次のとおりでした。
出力番号 |
時刻 |
|
|---|---|---|
1 |
0.01 |
\(5.9\times10^{-7}\) |
10 |
0.10 |
\(9.3\times10^{-7}\) |
40 |
0.40 |
\(1.6\times10^{-6}\) |
80 |
0.80 |
\(1.7\times10^{-6}\) |
Release と Debug が一致するかどうかは、コンパイラや計算機によって変わります。上の結果は一例です。
float の丸め誤差の目安
MISO のデモとテストは using Real = float;(単精度)で計算しています。
実数は有限の桁で表すので、計算のたびに結果が表せる値に丸められます。
1 と、その次に大きい表せる数との差を計算機イプシロン(machine epsilon)と呼びます。
型 |
計算機イプシロン |
10 進でおよその有効桁 |
|---|---|---|
|
\(2^{-23} \approx 1.19\times10^{-7}\) |
7 桁 |
|
\(2^{-52} \approx 2.22\times10^{-16}\) |
16 桁 |
1 回の四則演算での丸めの誤差は、相対的に計算機イプシロンの半分以下です。
上の例の 0.1f を 10 回足した結果は 1.00000012 で、1 との差 \(1.19\times10^{-7}\) は float の計算機イプシロン 1 つ分です。
差を判断するときの目安は次のとおりです。
相対的な差 |
判断の目安( |
|---|---|
0 |
ビット単位で同一。計算の順序も変わっていません |
\(10^{-7}\)〜\(10^{-5}\) 程度 |
丸め誤差程度。演算の順序や命令の違い(加算の順序、FMA、数学関数の実装、CPU と GPU の違い)で起こります |
それより大きい |
計算の内容が変わった可能性があります。入力、格子、時間刻み、境界条件、変更した式を確かめます |
これは目安で、決まった許容値ではありません。 丸め誤差は時間発展のステップごとに積み重なり、乱流のように小さな差が育つ流れでは、長い時間の後に大きな差になることがあります。 そのような比較では、早い時刻で差が丸め誤差程度であることを確かめ、差が時間とともにどう増えるかも見ます。 桁落ち(近い値どうしの引き算で有効桁が減ること)のある式では、差が計算機イプシロンより大きくなることもあります。
比較条件の記録
後で同じ比較をやり直せるように、次を記録しておきます。
MISO の版(
git log -1 --onelineの表示)と、変更の内容コンパイラとその版、ビルド種別、CPU 版か GPU 版か
使ったデモと
config.yaml、MPI のプロセス数と分割比べた出力番号と変数、比較の方法(
cmp、compare.pyなど)と得られた値
MISO に PR を出すときは、実行したコマンドと比べた数値を PR の本文に書きます(MISO の dev/notes/rules_ai.md)。