Testing and Comparing Results
This page collects ways to confirm that you have not broken anything when you change MISO. The first half covers running and reading MISO’s unit tests (doctest and ctest); the second half covers how to compare demo output and a rule of thumb for float rounding error. It assumes the build procedure of Chapter 1.
What to compare
The degree of agreement you expect depends on what you compare.
Comparison |
What it checks |
Expected agreement |
|---|---|---|
The result of a component versus the expected value from a formula or hand calculation |
Indices, coefficients, and boundary handling |
Exact for integers. About the rounding error for real numbers |
Two runs with the same executable and the same settings |
That the result does not change from run to run |
Bit-for-bit identical |
Before and after a change (a change that should not alter the computation) |
That the rewrite did not change the computation |
Bit-for-bit identical, or about the rounding error |
Runs with a different number or direction of MPI decomposition |
Domain decomposition and communication |
Bit-for-bit identical as a whole array, or about the rounding error |
The CPU and GPU versions, and differences in compiler or optimization |
The effect of differences in the order of operations or in instructions |
About the rounding error (it grows with time evolution) |
Numerical and exact solutions |
The discretization (grid and time step) error |
That it becomes smaller as the grid is refined |
In a MISO PR, describe the effect on numerical results as one of “bit-for-bit identical”, “about the rounding error”, “discretization error”, or “physically changed” (MISO’s dev/notes/rules_ai.md). The methods on this page give you the material to judge which one applies.
Compute the expected value from a formula or a separate simple computation, without using the function you want to check as it is. If you build the expected value with the same function, the function’s mistake also enters the expected value, and the comparison cannot find it.
Running the unit tests
Running them all together (ctest)
MISO’s unit tests are in miso/tests/, and they are built together when you build MISO by itself (ENABLE_TESTING is ON by default). The build folder may be outside the MISO folder.
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
The number and names of the tests change with the MISO version. --output-on-failure shows only the output of failed tests. It does not show the output of passing tests.
An environment variable written in the form NAME=value command is in effect only during that command. The two environment variables change the behavior of Open MPI.
Environment variable |
Meaning |
|---|---|
|
Avoids a memory-copy mechanism for communication between processes on the same machine, which may be unavailable in containers and the like |
|
Allows launching more processes than there are CPU cores. This lets you run the 2-process tests even on a machine with few cores |
It may work on your own machine without them, but MISO’s procedure (dev/notes/usage_ai.md) and GitHub CI run in this form. Multi-process tests are also launched through miso/shared/mpiexec_with_env.sh, which also loads the settings in miso/shared/openmpi_config.env.
Selecting tests
Command |
What it does |
|---|---|
|
Lists the registered tests without running them |
|
Runs only the tests whose names contain |
|
Runs the selected tests, showing the run command and all of the output |
The name of a test registered with ctest is the source file name followed by _cpp (_cu for the GPU version). miso/tests/serial/CMakeLists.txt and miso/tests/parallel/CMakeLists.txt collect the files matching test_*.cpp and register them automatically. If you put a new test file in serial/, it is registered the next time you run the configuration.
Running a test executable directly
Each test is an independent executable, located in build_miso/tests/serial/ and build_miso/tests/parallel/. If you run it directly, you can narrow down what runs further with doctest options.
./tests/serial/test_execution_cpp --list-test-cases
./tests/serial/test_execution_cpp --test-case="Test reduce 1D CPU" --success
Option |
Meaning |
|---|---|
|
Lists the names of the test cases contained in the file |
|
Runs only the test cases whose names match. You can use |
|
Also shows the successful checks |
|
Suppresses the control characters for text color. Use it when you save the log to a file |
The tests in parallel/ run with multiple MPI processes. To launch one in the same form as ctest, do the following.
/path/to/MISO/miso/shared/mpiexec_with_env.sh -n 2 ./tests/parallel/test_parallel_grid_cpp
Each process prints a summary of its own results, so the summary appears twice. Check the summaries of all the processes.
The GPU tests (*.cu) are built only with -DMISO_USE_CUDA=ON. See Chapter 9.
Reading a test
MISO’s unit tests are written with a testing library called doctest. doctest consists of a single header, and FetchContent in miso/tests/CMakeLists.txt fetches it from GitHub at configuration time.
Let us read the first test in miso/tests/serial/test_execution.cpp (excerpt).
#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);
}
Syntax |
Meaning |
|---|---|
|
This tells doctest to create the |
|
Defines one test case. What is inside |
|
If the condition does not hold, it counts as a failure and the rest of that test case is not executed |
|
If the condition does not hold, it is recorded as a failure, and execution continues |
This test runs a for_each that puts i * i only into indices 2 and 3, and checks those two values. It is an integer computation, so it requires an exact match with ==. for_each, Range1D, Array1D, and lambda expressions are explained in Chapter 7.
Comparing real numbers: doctest::Approx
In real-number computations, even mathematically equivalent expressions can differ in the last digits depending on the order of computation (a later section). For this reason, to compare real numbers, use doctest::Approx, not ==, to check that they are “close enough”.
The following is an example that checks, in two ways, the result of adding 0.1 ten times in float. The doctest header is in _deps/doctest-src/ in the folder where you built MISO’s tests.
#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!
The Approx check succeeds, and the == check fails. s is 1.00000012, which differs from 1 only in the last digit. In the failure message values: CHECK( 1 == 1 ), both sides look the same, but this is because the display has few digits. When a comparison of real numbers fails, increase the number of digits yourself and print the values to check. If even one test fails, the executable returns a nonzero exit code, and ctest shows that test as Failed.
a == doctest::Approx(b) succeeds when the following condition holds (the implementation in doctest 2.4.12).
You specify \(\varepsilon\) with .epsilon(value) and \(s\) with .scale(value). By default, \(\varepsilon\) is 100 times the machine epsilon of float (about \(1.2\times10^{-5}\)) and \(s = 0\), so the comparison uses the relative error. With .scale(1.0), for values near 0 the comparison uses \(\varepsilon\) as the tolerance of the absolute error. This is because, at a point where the expected value is 0, using a relative error makes any small difference a failure.
An example of an integration test in MISO
miso/tests/parallel/test_hd1d_shock_tube.cpp is a test that actually evolves a 1D shock tube (Sod’s problem) in time with MISO. It makes two kinds of comparison (excerpt).
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));
The first is a comparison with the exact solution. It checks that the sum of the absolute differences divided by the sum of the values is less than 5%. The difference from the exact solution includes the discretization error, so the tolerance is fairly large.
The second is a comparison between computations of the same problem placed along the x, y, and z directions. Mathematically these are the same computation, so it requires agreement to about the rounding error (\(\varepsilon = 10^{-5}\)).
Comparing demo output
To check whether a change to MISO has altered the computed results, run the demo before and after the change and compare the output.
Runs for comparison
The output destination of a demo, io.save_dir (data/ by default), is a path relative to the folder containing config.yaml. So if you create a separate folder for each run, copy config.yaml into it, and specify that file with --config, you can keep the outputs separate. This is an example of running twice, assuming demo/mhd2d_vortex has been built.
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
From set -a to set +a, you make the environment variables written in openmpi_config.env take effect in this terminal (app_run.sh does the same thing).
The demo’s config.yaml has io.continue: true, so if results already exist in the output destination, the run continues from them. Do each run for comparison in an empty folder.
When comparing before and after a change, use the executables built from the respective versions of MISO. If you compare within the same MISO folder, either copy the pre-change executable under another name, or use separate build folders.
Bit-for-bit comparison (cmp, diff -rq)
Check whether two files differ by even one byte with cmp.
cmp run_a/data/mhd/mhd.00000080.00000000.bin run_b/data/mhd/mhd.00000080.00000000.bin
If they are the same it prints nothing; if they differ it prints the position of the first differing byte. You can compare whole folders with diff -rq.
diff -rq run_a/data run_b/data
If nothing is printed, all the outputs are bit-for-bit identical.
Bit-for-bit comparison can be used only when the layout of the output is the same. If you change the number or direction of the MPI decomposition, the range covered by each rank’s file (mhd.<n_output>.<rank>.bin) changes, so the files do not match even when the values inside are the same. In that case, assemble them into a whole array as follows and then compare.
Comparing as a whole array (pymiso)
pymiso.Data reads the file of each rank and assembles them into a whole array (the ghost cells are removed). The following script takes the last output numbers of the two outputs and, for each variable, prints the maximum difference divided by a representative value (the maximum absolute value of that variable). Some points have an expected value of 0, so we divide by the representative value, not use a pointwise relative error.
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}")
Save it as compare.py and run it in the virtual environment where you installed pymiso in Chapter 1.
python compare.py run_a/data run_b/data
a.time is an object that handles the time file, and the time value is a.time.time. Convert the differences to float64 before computing. If you subtract in float32, rounding also enters the difference itself. vz and bz are all 0 in this 2D problem, so they cannot be divided by the representative value.
Examples of comparison
These are the results of comparing under different conditions with the default settings of demo/mhd2d_vortex (128×128, 354 steps up to time 0.8, Real is float) (Ubuntu 24.04, GCC 13.3, Open MPI 4.1).
What was compared |
|
|
|---|---|---|
Two runs with the same executable |
No difference |
All 0 |
Executables built as Release and Debug |
No difference |
All 0 |
Changing the MPI decomposition from 2 in the x direction to 2 in the y direction |
All |
All 0 |
Executable built with |
|
\(1.7\times10^{-6}\) to \(5.7\times10^{-6}\) depending on the variable |
The computation with a changed decomposition differed as files, but was bit-for-bit identical as a whole array. -march=native tells the compiler to use instructions specific to the CPU in use (such as FMA, which merges multiply and add). Because the rounding of operations changes, the result changes by about the rounding error. The time step determined by the CFL condition also changes in its last digits, and some output times differed in the 7th digit. The change of the difference with time in this comparison was as follows.
Output number |
Time |
Difference in |
|---|---|---|
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}\) |
Whether Release and Debug agree depends on the compiler and the machine. The result above is one example.
Rule of thumb for float rounding error
MISO’s demos and tests compute with using Real = float; (single precision). Real numbers are represented with a finite number of digits, so each computation rounds the result to a representable value. The difference between 1 and the next larger representable number is called the machine epsilon.
Type |
Machine epsilon |
Approximate significant decimal digits |
|---|---|---|
|
\(2^{-23} \approx 1.19\times10^{-7}\) |
7 digits |
|
\(2^{-52} \approx 2.22\times10^{-16}\) |
16 digits |
The rounding error of one arithmetic operation is at most half the machine epsilon in relative terms. The result of adding 0.1f ten times in the example above is 1.00000012, and its difference from 1, \(1.19\times10^{-7}\), is one machine epsilon of float.
The following is a rule of thumb for judging a difference.
Relative difference |
How to judge it (for |
|---|---|
0 |
Bit-for-bit identical. The order of computation has not changed either |
About \(10^{-7}\) to \(10^{-5}\) |
About the rounding error. It arises from differences in the order of operations or in instructions (the order of addition, FMA, the implementation of math functions, the difference between CPU and GPU) |
Larger than that |
The content of the computation may have changed. Check the input, the grid, the time step, the boundary conditions, and the expressions you changed |
This is a rule of thumb, not a fixed tolerance. Rounding errors accumulate at each time-evolution step, and in a flow where small differences grow, such as turbulence, the difference can become large after a long time. In such a comparison, check that the difference is about the rounding error at an early time, and also look at how the difference grows with time. In an expression with cancellation (the loss of significant digits in a subtraction of nearby values), the difference can become larger than the machine epsilon.
Recording the comparison conditions
So that you can redo the same comparison later, record the following.
The MISO version (the output of
git log -1 --oneline) and the content of the changeThe compiler and its version, the build type, and whether it is the CPU or the GPU version
The demo and
config.yamlyou used, and the number of MPI processes and the decompositionThe output numbers and variables compared, the comparison method (
cmp,compare.py, and so on), and the values obtained
When you open a PR for MISO, write the commands you ran and the numbers you compared in the body of the PR (MISO’s dev/notes/rules_ai.md).