Add manual test for CG converence

Also add MJTOL_SCALE to fixture to allow tests to be run with zero tolerance. This is useful when assesing the impact of code changes (A/B comparison of failure values)

PiperOrigin-RevId: 924219083
Change-Id: Ifdd09ac850904ca8dd79179930ce738a4b37d284
This commit is contained in:
Yuval Tassa
2026-05-31 03:15:06 -07:00
committed by Copybara-Service
parent 50e823e91c
commit 4358a102cd
11 changed files with 512 additions and 74 deletions
+99 -45
View File
@@ -26,9 +26,8 @@
namespace mujoco {
namespace {
using ::testing::NotNull;
using ::testing::Pointwise;
using ::std::max;
using ::testing::NotNull;
using SolverTest = MujocoTest;
@@ -60,16 +59,14 @@ TEST_F(SolverTest, IslandsEquivalent) {
mjtNum maxiter[kNumTol] = {30, 40, 60};
// Below are 3 tolerances associated with 3 different iteration counts.
// Tolerances are set to be ~12x higher than failure thresholds.
// For float32, failure thresholds are ~6000x larger than for float64.
// Line 99 adds a 500x factor for float32, so we need another ~12x in rtol.
// The point of this test is to show that CG convergence is actually not very
// precise, simply changing whether islands are used changes the solution by
// quite a lot, even at high iteration count and zero {ls_}tolerance.
// Increasing the iteration count higher than 60 does not improve convergence.
mjtNum rtol[kNumTol] = {
MjTol(6e-2, 7.2e-1),
MjTol(6e-3, 7.2e-2),
MjTol(6e-4, 7.2e-3)
MjTol(1e-1, 2),
MjTol(3e-2, 1),
MjTol(1.5e-4, 3.6)
};
for (int i = 0; i < kNumTol; ++i) {
@@ -85,6 +82,14 @@ TEST_F(SolverTest, IslandsEquivalent) {
model->opt.disableflags &= ~mjDSBL_WARMSTART;
}
mjtNum max_ratio = 0;
mjtNum worst_diff = 0;
mjtNum worst_scale = 1.0;
mjtNum worst_expected = 0;
mjtNum worst_actual = 0;
std::string worst_time = "";
int worst_dof = -1;
while (data_noisland->time < .1) {
mj_getState(model, data_noisland, state, mjSTATE_INTEGRATION);
mj_setState(model, data_island, state, mjSTATE_INTEGRATION);
@@ -95,22 +100,38 @@ TEST_F(SolverTest, IslandsEquivalent) {
model->opt.disableflags |= mjDSBL_ISLAND; // disable islands
mj_forward(model, data_noisland);
auto time = std::to_string(data_noisland->time);
for (int j = 0; j < nv; j++) {
// increase tolerance for large elements
mjtNum diff = std::abs(data_noisland->qacc[j] - data_island->qacc[j]);
mjtNum scale = 0.5 * max(static_cast<mjtNum>(2.0),
std::abs(data_noisland->qacc[j]) +
std::abs(data_island->qacc[j]));
EXPECT_NEAR(data_noisland->qacc[j], data_island->qacc[j],
MjTol(scale * rtol[i], 500 * scale * rtol[i]))
<< "time: " << time << '\n'
<< "dof: " << j << '\n'
<< "maxiter: " << maxiter[i] << '\n'
<< "rtol: " << scale * rtol[i];
std::abs(data_island->qacc[j]));
mjtNum ratio = diff / scale;
if (ratio > max_ratio) {
max_ratio = ratio;
worst_diff = diff;
worst_scale = scale;
worst_expected = data_island->qacc[j];
worst_actual = data_noisland->qacc[j];
worst_time = std::to_string(data_noisland->time);
worst_dof = j;
}
}
mj_step(model, data_noisland);
}
// Assert once per condition with the worst offender.
// rtol[i] is already scaled by MjTolScale() at initialization.
mjtNum allowed_tol = worst_scale * rtol[i];
EXPECT_NEAR(worst_actual, worst_expected, allowed_tol)
<< "Worst offender info:\n"
<< "time: " << worst_time << '\n'
<< "dof: " << worst_dof << '\n'
<< "maxiter: " << maxiter[i] << '\n'
<< "coldstart: " << coldstart << '\n'
<< "rtol: " << worst_scale * rtol[i] << '\n'
<< "actual diff: " << worst_diff << " (allowed: " << allowed_tol
<< ")";
}
}
@@ -161,16 +182,36 @@ TEST_F(SolverTest, IslandsEquivalentForward) {
model->opt.disableflags &= ~mjDSBL_ISLAND; // enable islands
mj_forward(model, data_island);
mjtNum max_diff = 0;
mjtNum worst_expected = 0;
mjtNum worst_actual = 0;
int worst_idx = -1;
mjtNum scale = 0.5 * (mju_norm(data_noisland->qacc, nv) +
mju_norm(data_island->qacc, nv));
mjtNum tol = scale * (solver == mjSOL_CG ? 1e-6 : 1e-8);
EXPECT_THAT(AsVector(data_island->qacc, nv),
Pointwise(MjNear(scale * tol, 500 * scale * tol),
AsVector(data_noisland->qacc, nv)))
mjtNum rtol = solver == mjSOL_CG ? MjTol(1e-6, 1e-2)
: MjTol(1e-13, 1e-3);
mjtNum worst_allowed = scale * rtol;
for (int j = 0; j < nv; j++) {
mjtNum diff =
std::abs(data_island->qacc[j] - data_noisland->qacc[j]);
if (diff > max_diff) {
max_diff = diff;
worst_expected = data_noisland->qacc[j];
worst_actual = data_island->qacc[j];
worst_idx = j;
}
}
EXPECT_NEAR(worst_actual, worst_expected, worst_allowed)
<< "Worst offender in IslandsEquivalentForward:\n"
<< "idx: " << worst_idx << '\n'
<< "warmstart: " << warmstart << '\n'
<< "jacobian: " << (jacobian ? "sparse" : "dense") << '\n'
<< "solver: " << (solver == mjSOL_CG ? "CG" : "Newton") << '\n'
<< "cone: " << (cone == 1 ? "elliptic" : "pyramidal");
<< "cone: " << (cone == 1 ? "elliptic" : "pyramidal") << '\n'
<< "actual diff: " << max_diff << " (allowed: " << worst_allowed
<< ")";
}
}
}
@@ -183,15 +224,14 @@ TEST_F(SolverTest, IslandsEquivalentForward) {
TEST_F(SolverTest, SolversEquivalent) {
struct SolverTolerances {
double newton;
double cg;
double pgs_pyramidal;
double pgs_elliptic;
mjtNum newton;
mjtNum cg;
mjtNum pgs_pyramidal;
mjtNum pgs_elliptic;
};
// Base relative tolerances are factor of 10 above failure thresholds
// on Linux, clang, x86-64 (i.e., test just passes with tol_multiplier = 1)
// TODO: Get float32 tolerances
// Relative tolerances: 10x above failure thresholds on Linux, clang, x86-64.
// MjTol(f64, f32) selects the appropriate tolerance for the current build.
const struct {
const char* path;
SolverTolerances tolerances;
@@ -199,18 +239,18 @@ TEST_F(SolverTest, SolversEquivalent) {
{.path = kModelPath,
.tolerances =
{
.newton = 1e-15,
.cg = 1e-7,
.pgs_pyramidal = 1e-14,
.pgs_elliptic = 1e-3,
.newton = MjTol(1e-13, 1e-5),
.cg = MjTol(1e-5, 1e-2),
.pgs_pyramidal = MjTol(1e-12, 1e-5),
.pgs_elliptic = MjTol(1e-3, 1e-2),
}},
{.path = kHumanoidPath,
.tolerances =
{
.newton = 1e-15,
.cg = 1e-7,
.pgs_pyramidal = 1e-6,
.pgs_elliptic = 1e-9,
.newton = MjTol(1e-13, 1e-5),
.cg = MjTol(1e-5, 1e-2),
.pgs_pyramidal = MjTol(1e-5, 1e-5),
.pgs_elliptic = MjTol(1e-8, 1e-4),
}},
};
@@ -241,7 +281,7 @@ TEST_F(SolverTest, SolversEquivalent) {
mjtNum scale = mju_norm(data_truth->qfrc_constraint, nv);
for (mjtSolver solver : {mjSOL_NEWTON, mjSOL_CG, mjSOL_PGS}) {
double rtol;
mjtNum rtol;
switch (solver) {
case mjSOL_NEWTON:
rtol = config.tolerances.newton;
@@ -255,9 +295,7 @@ TEST_F(SolverTest, SolversEquivalent) {
break;
}
// increase base tolerance to avoid test flakiness
double tol_multiplier = 1e2;
double tolerance = scale * rtol * tol_multiplier;
mjtNum tolerance = scale * rtol;
for (mjtJacobian jacobian : {mjJAC_DENSE, mjJAC_SPARSE}) {
model->opt.solver = solver;
@@ -274,15 +312,31 @@ TEST_F(SolverTest, SolversEquivalent) {
const char* jacobian_str =
(jacobian == mjJAC_DENSE ? "dense" : "sparse");
EXPECT_THAT(AsVector(data->qfrc_constraint, nv),
Pointwise(MjNear(tolerance,
max(1e-1, 1000 * tolerance)),
AsVector(data_truth->qfrc_constraint, nv)))
mjtNum max_diff = 0;
mjtNum worst_expected = 0;
mjtNum worst_actual = 0;
int worst_idx = -1;
for (int j = 0; j < nv; j++) {
mjtNum diff = std::abs(data->qfrc_constraint[j] -
data_truth->qfrc_constraint[j]);
if (diff > max_diff) {
max_diff = diff;
worst_expected = data_truth->qfrc_constraint[j];
worst_actual = data->qfrc_constraint[j];
worst_idx = j;
}
}
EXPECT_NEAR(worst_actual, worst_expected, tolerance)
<< "Worst offender in SolversEquivalent:\n"
<< "idx: " << worst_idx << '\n'
<< "model: " << config.path << "\n"
<< "cone: " << cone_str << "\n"
<< "solver: " << solver_str << "\n"
<< "jacobian: " << jacobian_str << "\n"
<< "tolerance: " << tolerance;
<< "actual diff: " << max_diff << " (allowed: " << tolerance
<< ")";
}
}
}