Files
Mujoco_WASM/test/engine/engine_util_solve_test.cc
T
Yuval Tassa 0afafacfc5 Add fixed-size 6x6 dense LU factorization with benchmark.
mju_factorLU6/mju_solveLU6: same algorithm as mju_factorLU/mju_solveLU
with compile-time size, allowing full unrolling. At n=6, factor+solve is
25% faster than the runtime-sized version (93 vs 124 ns), and fixed-size
LU factorization is faster than generic dense Cholesky (55 vs 61 ns):
at this size, runtime-n loop overhead outweighs Cholesky's 2x flop
advantage. See new lu_benchmark_test. Results agree with the generic
version to rounding, not bitwise: the compiler may fuse (FMA) the
unrolled version differently.

Also add two DenseLU tests: a pivoting-required matrix with zero
diagonal, and fixed-vs-generic agreement.

PiperOrigin-RevId: 947705056
Change-Id: I24c54c9510964aa376886e9dd721890eda9889d3
2026-07-14 08:38:19 -07:00

1311 lines
40 KiB
C++

// Copyright 2021 DeepMind Technologies Limited
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Tests for engine/engine_util_solve.c.
#include "src/engine/engine_util_solve.h"
#include <cstddef>
#include <iomanip>
#include <iostream>
#include <limits>
#include <random>
#include <string>
#include <vector>
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <mujoco/mujoco.h>
#include "src/engine/engine_util_blas.h"
#include "src/engine/engine_util_misc.h"
#include "src/engine/engine_util_sparse.h"
#include "test/fixture.h"
namespace mujoco {
namespace {
using ::testing::Pointwise;
using ::std::setw;
using ::std::string;
using ::std::vector;
using ::testing::ElementsAre;
using QCQP2Test = MujocoTest;
TEST_F(QCQP2Test, DegenerateAMatrix) {
// A 2x2 matrix with determinant zero.
const mjtNum Ain[9]{6, -15, 2, -5};
// Any values will do for these three inputs.
const mjtNum bin[3]{-12, 49};
const mjtNum d[3]{11, 31};
const mjtNum r = 0.01;
// Make output array explicitly nonzero to simulate uninitialized memory.
mjtNum res[2]{999, 999};
EXPECT_EQ(mju_QCQP2(res, Ain, bin, d, r), 0);
EXPECT_EQ(res[0], 0);
EXPECT_EQ(res[1], 0);
}
using QCQP3Test = MujocoTest;
TEST_F(QCQP3Test, DegenerateAMatrix) {
// A 3x3 matrix with determinant zero.
const mjtNum Ain[9]{1, 4, -2, -3, -7, 5, 2, -9, 0};
// Any values will do for these three inputs.
const mjtNum bin[3]{-12, 49, 8};
const mjtNum d[3]{11, 31, -23};
const mjtNum r = 0.1;
// Make output array explicitly nonzero to simulate uninitialized memory.
mjtNum res[3]{999, 999, 999};
EXPECT_EQ(mju_QCQP3(res, Ain, bin, d, r), 0);
EXPECT_EQ(res[0], 0);
EXPECT_EQ(res[1], 0);
EXPECT_EQ(res[2], 0);
}
// --------------------------- mju_boxQP ---------------------------------------
using BoxQPTest = MujocoTest;
// utility: compute QP objective = 0.5*x'*H*x + x'*g
mjtNum objective(const mjtNum* x, const mjtNum* H, const mjtNum* g, int n) {
return 0.5 * mju_mulVecMatVec(x, H, x, n) + mju_dot(x, g, n);
}
// utility: test if res is the minimum of a given box-QP problem
bool isQPminimum(const mjtNum* res, const mjtNum* H, const mjtNum* g, int n,
const mjtNum* lower, const mjtNum* upper) {
const mjtNum eps = MjTol(1e-4, 5e-2); // epsilon used for nudging
const mjtNum threshold = MjTol(0, -2e-3); // comparison threshold
bool is_minimum = true;
mjtNum* res_nudge = (mjtNum*)mju_malloc(sizeof(mjtNum) * n);
// get solution value
mjtNum value = objective(res, H, g, n);
mjtNum value_nudge;
// compare to nudged solution
mju_copy(res_nudge, res, n);
for (int i = 0; i < n; i++) {
// nudge down
res_nudge[i] = res[i] - eps;
if (lower) {
res_nudge[i] = mju_max(lower[i], res_nudge[i]);
}
value_nudge = objective(res_nudge, H, g, n);
if (value_nudge - value < threshold) {
is_minimum = false;
break;
}
// nudge up
res_nudge[i] = res[i] + eps;
if (upper) {
res_nudge[i] = mju_min(upper[i], res_nudge[i]);
}
value_nudge = objective(res_nudge, H, g, n);
if (value_nudge - value < threshold) {
is_minimum = false;
break;
}
// reset
res_nudge[i] = res[i];
}
mju_free(res_nudge);
return is_minimum;
}
// utility: define QP with pseudorandom values
void randomBoxQP(int n, mjtNum* H, mjtNum* g, mjtNum* lower, mjtNum* upper,
int seed) {
// make distribution using seed
std::mt19937_64 rng;
rng.seed(seed);
std::normal_distribution<double> dist(0, 1);
// square root of H
mjtNum* sqrtH = (mjtNum*)mju_malloc(sizeof(mjtNum) * n * n);
for (int i = 0; i < n; i++) {
g[i] = dist(rng);
lower[i] = 5 * dist(rng);
upper[i] = 5 * dist(rng);
// fix invalid bounds
if (lower[i] > upper[i]) {
mjtNum tmp = upper[i];
upper[i] = lower[i];
lower[i] = tmp;
}
// sample temp
for (int j = 0; j < n; j++) {
sqrtH[n * i + j] = dist(rng);
}
}
// make SPD matrix H
mju_mulMatTMat(H, sqrtH, sqrtH, n, n, n);
mju_free(sqrtH);
}
// test mju_boxQP on a small unbounded QP
TEST_F(BoxQPTest, UnboundedQP) {
// small arrays, allocate on stack
static const int n = 2;
mjtNum H[n * n] = {2, 0, 0, 2};
mjtNum g[n] = {1, 3};
mjtNum res[n] = {0, 0};
mjtNum R[n * (n + 7)];
int nfree = mju_boxQP(res, R, /*index=*/nullptr, H, g, n,
/*lower=*/nullptr, /*upper=*/nullptr);
// no bounds, expect Newton point
EXPECT_EQ(nfree, 2);
EXPECT_MJTNUM_EQ(res[0], -g[0] / H[0]);
EXPECT_MJTNUM_EQ(res[1], -g[1] / H[3]);
// check that solution is actual minimum
EXPECT_TRUE(isQPminimum(res, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr));
// perturb solution, expected it no longer be the minimum
res[0] += MjTol(0.001, 0.1);
EXPECT_FALSE(isQPminimum(res, H, g, n, /*lower=*/nullptr, /*upper=*/nullptr));
// negative-definite Hessian, no solution
H[0] = -1;
nfree = mju_boxQP(res, R, /*index=*/nullptr, H, g, n,
/*lower=*/nullptr, /*upper=*/nullptr);
EXPECT_EQ(nfree, -1);
}
// small bounded QP with asymmetric Hessian (upper triangle ignored)
TEST_F(BoxQPTest, AsymmetricUpperIgnored) {
// small arrays, allocate on stack
static const int n = 2;
mjtNum H[n * n] = {1, -400, 0, 1};
mjtNum g[n] = {1, 3};
mjtNum res[n] = {0, 0};
mjtNum lower[n] = {-2, -2};
mjtNum upper[n] = {0, 0};
int index[n];
mjtNum R[n * (n + 7)];
// solve box-QP
int nfree = mju_boxQP(res, R, index, H, g, n, lower, upper);
EXPECT_EQ(nfree, 1);
EXPECT_MJTNUM_EQ(res[0], -g[0] / H[0]);
EXPECT_MJTNUM_EQ(res[1], lower[1]);
}
// verify mju_boxQP reads only the lower triangle of H by poisoning the upper
// triangle with NaN and comparing to a clean symmetric solve
TEST_F(BoxQPTest, UpperTrianglePoisoned) {
int n = 30;
const mjtNum nan = std::numeric_limits<mjtNum>::quiet_NaN();
// allocate on heap
mjtNum *H, *g, *lower, *upper; // inputs
mjtNum *res, *R; // outputs
int* index; // outputs
mju_boxQPmalloc(&res, &R, &index, &H, &g, n, &lower, &upper);
struct Cleanup {
mjtNum *res, *R, *H, *g, *lower, *upper;
int* index;
~Cleanup() {
mju_free(res);
mju_free(R);
mju_free(index);
mju_free(H);
mju_free(g);
mju_free(lower);
mju_free(upper);
}
} cleanup{res, R, H, g, lower, upper, index};
// generate a symmetric SPD Hessian and bounded QP problem
randomBoxQP(n, H, g, lower, upper, /*seed=*/1);
int maxiter = 100;
mjtNum mingrad = MjTol(1E-16, 1E-5);
mjtNum backtrack = 0.5;
mjtNum minstep = MjTol(1E-22, 1E-10);
mjtNum armijo = 0.1;
// solve with symmetric H to get the reference result
mju_zero(res, n);
int nfree_ref =
mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad,
backtrack, minstep, armijo, nullptr, 0);
ASSERT_GT(nfree_ref, -1);
// save reference
std::vector<mjtNum> res_ref(res, res + n);
std::vector<int> index_ref(index, index + n);
std::vector<mjtNum> R_ref(R, R + nfree_ref * nfree_ref);
// poison the strict upper triangle of H with NaN
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
H[n * i + j] = nan;
}
}
// solve again; result must match because only lower triangle should be read
mju_zero(res, n);
int nfree_poisoned =
mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter, mingrad,
backtrack, minstep, armijo, nullptr, 0);
EXPECT_EQ(nfree_poisoned, nfree_ref);
for (int i = 0; i < n; i++) {
EXPECT_EQ(res[i], res_ref[i]) << "mismatch at index " << i;
}
for (int i = 0; i < nfree_ref; i++) {
EXPECT_EQ(index[i], index_ref[i]) << "index mismatch at " << i;
for (int j = 0; j <= i; j++) {
int k = i * nfree_ref + j;
EXPECT_EQ(R[k], R_ref[k]) << "R mismatch at row " << i << ", col " << j;
}
}
}
// test mju_boxQP on a single random bounded QP
TEST_F(BoxQPTest, BoundedQP) {
int n = 50; // problem size
// allocate on heap
mjtNum *H, *g, *lower, *upper; // inputs
mjtNum *res, *R; // outputs
int* index; // outputs
mju_boxQPmalloc(&res, &R, &index, &H, &g, n, &lower, &upper);
randomBoxQP(n, H, g, lower, upper, /*seed=*/1);
// initialize res
mju_zero(res, n);
// use default options
int maxiter = 100; // maximum number of iterations
mjtNum mingrad =
MjTol(1E-16, 1E-5); // minimum squared norm of (unclamped) gradient
mjtNum backtrack = 0.5; // backtrack factor for decreasing stepsize
mjtNum minstep = MjTol(1E-22, 1E-10); // minimum stepsize for linesearch
mjtNum armijo = 0.1; // Armijo parameter
// logging
static const int logsz = 10000;
char log[logsz];
int nfree = mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter,
mingrad, backtrack, minstep, armijo, log, logsz);
// ADD_FAILURE() << log; // uncomment to print `log` to error log
// check solution
EXPECT_GT(nfree, -1);
EXPECT_TRUE(isQPminimum(res, H, g, n, lower, upper));
// verify clamping
int j = nfree > 0 ? 0 : -1;
for (int i = 0; i < n; i++) {
if (j >= 0 && i == index[j]) { // free dimension
EXPECT_GT(res[i], lower[i]);
EXPECT_LT(res[i], upper[i]);
j++;
} else { // clamped dimension
EXPECT_TRUE(res[i] == lower[i] || res[i] == upper[i]);
}
}
mju_free(res);
mju_free(R);
mju_free(index);
mju_free(H);
mju_free(g);
mju_free(lower);
mju_free(upper);
}
// test mju_boxQP on a set of random bounded QPs
TEST_F(BoxQPTest, BoundedQPvariations) {
if constexpr (sizeof(mjtNum) == sizeof(float)) {
GTEST_SKIP()
<< "BoxQP test permutations contain ill-conditioned matrices which "
"natively fail to find descent directions under float32 precision";
}
int nmax = 100;
// allocate maximum size on heap
mjtNum *H, *g, *lower, *upper; // inputs
mjtNum *res, *R; // outputs
int* index; // outputs
mju_boxQPmalloc(&res, &R, &index, &H, &g, nmax, &lower, &upper);
// logging
static const int logsz = 10000;
char log[logsz];
int seed = 1;
for (int n : {3, 30, 100}) {
int count = 0;
int factorizations = 0;
for (mjtNum scaleH : {.01, 1.0, 100.0}) {
for (mjtNum scaleg : {.01, 1.0, 100.0}) {
for (mjtNum scalebounds : {.01, 1.0, 100.0}) {
// make random box-QP
randomBoxQP(n, H, g, lower, upper, seed++);
mju_scl(H, H, scaleH, n * n);
mju_scl(g, g, scaleg, n);
mju_scl(lower, lower, scalebounds, n);
mju_scl(upper, upper, scalebounds, n);
// initialize with zeros
mju_zero(res, n);
// default algorithm options
int maxiter = 100;
mjtNum mingrad = MjTol(1E-16, 1E-5);
mjtNum backtrack = 0.5;
mjtNum minstep = MjTol(1E-22, 1E-10);
mjtNum armijo = 0.1;
// solve box-QP with logging
int nfree =
mju_boxQPoption(res, R, index, H, g, n, lower, upper, maxiter,
mingrad, backtrack, minstep, armijo, log, logsz);
// check solution
EXPECT_GT(nfree, -1) << log;
EXPECT_TRUE(isQPminimum(res, H, g, n, lower, upper))
<< "n " << n << '\n'
<< "scaleH " << scaleH << '\n'
<< "scaleg " << scaleg << '\n'
<< "scalebounds " << scalebounds << '\n';
// wrap log with string, count factorizations
string slog(log);
string factorstr = "factorizations=";
std::size_t index = slog.find(factorstr) + factorstr.length();
int num_factor = std::stoi(slog.substr(index, 3));
// never more than 6 factorizations
EXPECT_LE(num_factor, 6);
factorizations += num_factor;
count++;
}
}
}
double meanfactor = ((double)factorizations) / count;
// average of 4.5 factorizations is expected
EXPECT_LE(meanfactor, 5.0);
std::cerr << "n=" << setw(3) << n << ": average of " << meanfactor
<< " factorizations\n";
}
mju_free(res);
mju_free(R);
mju_free(index);
mju_free(H);
mju_free(g);
mju_free(lower);
mju_free(upper);
}
// ------------------------- band matrices -------------------------------------
using BandMatrixTest = MujocoTest;
// utility: random "arrowhead", banded-then-dense SPD matrix
// optional random vector and diagonal regularizer
void randomBanded(mjtNum* H, int nTotal, int nBand, int nDense, int seed,
mjtNum* vec = nullptr, mjtNum reg = 0) {
// make distribution using seed
std::mt19937_64 rng;
rng.seed(seed);
std::normal_distribution<double> dist(0, 1);
// allocate square root
mjtNum* sqrtH = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal * nTotal);
// sample
for (int i = 0; i < nTotal; i++) {
if (vec) vec[i] = dist(rng);
for (int j = 0; j < nTotal; j++) {
sqrtH[nTotal * i + j] = dist(rng);
}
}
// make SPD matrix H
mju_mulMatTMat(H, sqrtH, sqrtH, nTotal, nTotal, nTotal);
// set zeros
int nSparse = nTotal - nDense;
for (int i = 0; i < nSparse; i++) {
int nzeros = mjMAX(0, i + 1 - nBand);
for (int j = 0; j < nzeros; j++) {
H[nTotal * i + j] = 0;
H[nTotal * j + i] = 0;
}
}
// add regularizer to diagonal
for (int i = 0; i < nTotal; i++) {
H[nTotal * i + i] += reg;
}
mju_free(sqrtH);
}
// test banded-vector diagonal values
TEST_F(BandMatrixTest, Diagonal) {
int seed = 1;
int nTotal = 8;
for (int nBand : {1, 3}) {
for (int nDense : {0, 2}) {
// allocate
int nB = (nTotal - nDense) * nBand + nDense * nTotal;
mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB);
int nH = nTotal * nTotal;
mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
// make random banded SPD matrix, dense representation
randomBanded(H, nTotal, nBand, nDense, seed++);
// convert to banded representation
mju_dense2Band(B, H, nTotal, nBand, nDense);
// expect diagonals to be equal
for (int i = 0; i < nTotal; i++) {
EXPECT_EQ(H[i * nTotal + i], B[mju_bandDiag(i, nTotal, nBand, nDense)]);
}
mju_free(H);
mju_free(B);
}
}
}
// test conversion of banded <-> dense
TEST_F(BandMatrixTest, Conversion) {
int seed = 1;
int nTotal = 8;
for (int nBand : {0, 1, 3}) {
for (int nDense : {0, 2}) {
// allocate
int nB = (nTotal - nDense) * nBand + nDense * nTotal;
mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB);
int nH = nTotal * nTotal;
mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
// make random banded SPD matrix, dense
randomBanded(H, nTotal, nBand, nDense, seed++);
// convert to banded
mju_dense2Band(B, H, nTotal, nBand, nDense);
// convert back to dense
mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/1);
// expect exact equality
EXPECT_EQ(AsVector(H, nH), AsVector(H1, nH));
mju_free(H1);
mju_free(H);
mju_free(B);
}
}
}
// test banded-vector multiplication
TEST_F(BandMatrixTest, Multiplication) {
int seed = 1;
int nTotal = 8;
for (int nBand : {0, 1, 3}) {
for (int nDense : {0, 2}) {
// allocate
int nB = (nTotal - nDense) * nBand + nDense * nTotal;
mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB);
int nH = nTotal * nTotal;
mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
// make random banded SPD matrix, dense
randomBanded(H, nTotal, nBand, nDense, seed++, vec);
// multiply dense
mju_mulMatVec(res, H, vec, nTotal, nTotal);
// convert to banded, multiply
mju_dense2Band(B, H, nTotal, nBand, nDense);
mju_bandMulMatVec(res1, B, vec, nTotal, nBand, nDense,
/*nVec=*/1, /*flg_sym=*/1);
// expect numerical equality
mjtNum eps = MjTol(1e-12, 5e-5);
EXPECT_THAT(AsVector(res, nTotal),
Pointwise(MjNear(eps, eps), AsVector(res1, nTotal)));
mju_free(res1);
mju_free(res);
mju_free(vec);
mju_free(H);
mju_free(B);
}
}
}
// test banded factorization and vector product with factor
TEST_F(BandMatrixTest, Factorization) {
int seed = 1;
int nTotal = 8;
for (int nBand : {1, 3}) {
for (int nDense : {0, 2}) {
for (mjtNum diagadd : {0.0, 1.0}) {
for (int diagmul : {0.0, 1.3}) {
// allocate
int nB = (nTotal - nDense) * nBand + nDense * nTotal;
mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB);
int nH = nTotal * nTotal;
mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
// make random banded matrix, dense representation
// add regularizer to ensure PD
randomBanded(H, nTotal, nBand, nDense, seed++, vec, /*reg=*/nTotal);
// convert to banded
mju_dense2Band(B, H, nTotal, nBand, nDense);
// apply diagadd and diagmul
for (int i = 0; i < nTotal; i++) {
H[nTotal * i + i] += diagadd + diagmul * H[nTotal * i + i];
}
// in-place dense factorization
int rank = mju_cholFactor(H, nTotal, /*mindiag=*/0);
// expect factorization to have succeeded
EXPECT_EQ(rank, nTotal);
// banded factorization
mjtNum minDiag =
mju_cholFactorBand(B, nTotal, nBand, nDense, diagadd, diagmul);
// expect factorization to have succeeded
EXPECT_GT(minDiag, 0);
// convert back to dense, lower triangle only
mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/0);
// zero upper triangle of H (unused)
for (int i = 0; i < nTotal - 1; i++) {
mju_zero(H + nTotal * i + i + 1, nTotal - i - 1);
}
// expect numerical equality
mjtNum eps = MjTol(1e-12, 5e-5);
EXPECT_THAT(AsVector(H, nH),
Pointwise(MjNear(eps, eps), AsVector(H1, nH)));
// multiply dense
mju_mulMatVec(res, H, vec, nTotal, nTotal);
// multiply sparse, only lower triangle
mju_bandMulMatVec(res1, B, vec, nTotal, nBand, nDense,
/*nVec=*/1, /*flg_sym=*/0);
// expect numerical equality
EXPECT_THAT(AsVector(res, nTotal),
Pointwise(MjNear(eps, eps), AsVector(res1, nTotal)));
mju_free(res1);
mju_free(res);
mju_free(vec);
mju_free(H1);
mju_free(H);
mju_free(B);
}
}
}
}
}
// test banded solve
TEST_F(BandMatrixTest, Solve) {
int seed = 1;
int nTotal = 8;
for (int nBand : {1, 3}) {
for (int nDense : {0, 2}) {
// allocate
int nB = (nTotal - nDense) * nBand + nDense * nTotal;
mjtNum* B = (mjtNum*)mju_malloc(sizeof(mjtNum) * nB);
int nH = nTotal * nTotal;
mjtNum* H = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* H1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nH);
mjtNum* vec = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
mjtNum* res1 = (mjtNum*)mju_malloc(sizeof(mjtNum) * nTotal);
// make random banded matrix, dense representation
// add regularizer to ensure PD
randomBanded(H, nTotal, nBand, nDense, seed++, vec, /*reg=*/nTotal);
// convert to banded
mju_dense2Band(B, H, nTotal, nBand, nDense);
// in-place dense factorization
int rank = mju_cholFactor(H, nTotal, /*mindiag=*/0);
// expect factorization to have succeeded
EXPECT_EQ(rank, nTotal);
// banded factorization
mjtNum minDiag = mju_cholFactorBand(B, nTotal, nBand, nDense,
/*diagadd=*/0, /*diagmul=*/0);
// expect factorization to have succeeded
EXPECT_GT(minDiag, 0);
// convert back to dense, lower triangle only
mju_band2Dense(H1, B, nTotal, nBand, nDense, /*flg_sym=*/0);
// solve with dense
mju_cholSolve(res, H, vec, nTotal);
// solve with banded
mju_cholSolveBand(res1, B, vec, nTotal, nBand, nDense);
// expect numerical equality
mjtNum eps = MjTol(1e-12, 5e-5);
EXPECT_THAT(AsVector(res, nTotal),
Pointwise(MjNear(eps, eps), AsVector(res1, nTotal)));
mju_free(res1);
mju_free(res);
mju_free(vec);
mju_free(H1);
mju_free(H);
mju_free(B);
}
}
}
using EngineUtilSolveTest = MujocoTest;
TEST_F(EngineUtilSolveTest, MjuCholFactorSymbolic) {
MjModelPtr model = LoadModelFromString("<mujoco/>");
MjDataPtr d = MakeData(model);
// Test matrix (upper triangular, representing symmetric matrix):
// [10 1 2 3]
// [ 0 10 0 0]
// [ 0 0 10 1]
// [ 0 0 0 10]
//
// mju_cholFactorSymbolic reads entries where col >= row
int n = 4;
mjtNum H[16] = {10, 1, 2, 3, 0, 10, 0, 0, 0, 0, 10, 1, 0, 0, 0, 10};
// convert to sparse
mjtNum sparseH[16];
int H_rownnz[4], H_rowadr[4], H_colind[16];
mju_dense2sparse(sparseH, H, n, n, H_rownnz, H_rowadr, H_colind, 16);
// phase 1: counting
int L_rownnz[4], L_rowadr[4];
int LT_rownnz[4], LT_rowadr[4];
int nnz = mju_cholFactorSymbolic(nullptr, L_rownnz, L_rowadr, nullptr,
LT_rownnz, LT_rowadr, nullptr, H_rownnz,
H_rowadr, H_colind, n, d.get());
// verify counting phase outputs
EXPECT_EQ(nnz, 8);
// L (CSR) structure for reverse Cholesky (rows filled in reverse order)
EXPECT_THAT(AsVector(L_rownnz, 4), ElementsAre(1, 2, 2, 3));
EXPECT_THAT(AsVector(L_rowadr, 4), ElementsAre(0, 1, 3, 5));
// LT (CSC) structure: transpose of L
EXPECT_THAT(AsVector(LT_rownnz, 4), ElementsAre(4, 1, 2, 1));
EXPECT_THAT(AsVector(LT_rowadr, 4), ElementsAre(0, 4, 5, 7));
// phase 2: filling
int L_colind[8], LT_colind[8], LT_pos[8];
mju_cholFactorSymbolic(L_colind, L_rownnz, L_rowadr, LT_colind, LT_rownnz,
LT_rowadr, LT_pos, H_rownnz, H_rowadr, H_colind, n,
d.get());
// verify L_colind
EXPECT_THAT(AsVector(L_colind, 8), ElementsAre(0, 0, 1, 0, 2, 0, 2, 3));
// Explanation (reverse Cholesky builds from bottom to top):
// Row 0 (1 entry): diagonal 0
// Row 1 (2 entries): col 0, then diagonal 1
// Row 2 (2 entries): col 0, then diagonal 2
// Row 3 (3 entries): col 0, col 2, then diagonal 3
// verify LT_colind: transpose of L
EXPECT_THAT(AsVector(LT_colind, 8), ElementsAre(0, 1, 2, 3, 1, 2, 3, 3));
// Explanation:
// Column 0 (4 entries): rows 0, 1, 2, 3 (all have L[row, 0] != 0)
// Column 1 (1 entry): row 1
// Column 2 (2 entries): rows 2, 3
// Column 3 (1 entry): row 3
// verify LT_pos: for each entry in LT, should point to correct position in L
for (int c = 0; c < n; c++) {
int adr = LT_rowadr[c];
for (int k = 0; k < LT_rownnz[c]; k++) {
int L_idx = LT_pos[adr + k];
EXPECT_EQ(L_colind[L_idx], c)
<< "LT_pos mismatch at column " << c << ", entry " << k;
}
}
}
// Test for mju_cholUpdate: rank-one Cholesky update L*L' +/- x*x'
// Verifies that after applying the update, reconstructing H = L*L' produces
// the expected result H_original +/- x*x'.
TEST_F(EngineUtilSolveTest, MjuCholUpdate) {
std::mt19937_64 rng;
rng.seed(42);
std::normal_distribution<double> dist(0, 1);
for (int n : {4, 6, 8}) {
vector<mjtNum> H(n * n);
vector<mjtNum> H_expected(n * n);
vector<mjtNum> L_dense(n * n);
vector<mjtNum> sqrtH(n * n);
vector<mjtNum> x(n);
vector<mjtNum> x_copy(n);
vector<mjtNum> H_reconstructed(n * n);
for (int flg_plus : {0, 1}) {
// generate random lower-triangular matrix for sqrtH
mju_zero(sqrtH.data(), n * n);
for (int i = 0; i < n; i++) {
for (int j = 0; j <= i; j++) {
sqrtH[n * i + j] = dist(rng);
}
sqrtH[n * i + i] = mju_abs(sqrtH[n * i + i]) + n;
}
// create SPD matrix H = sqrtH * sqrtH' (forward-order Cholesky: L*L')
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
mjtNum sum = 0;
for (int k = 0; k <= mju_min(i, j); k++) {
sum += sqrtH[n * i + k] * sqrtH[n * j + k];
}
H[n * i + j] = sum;
}
}
// generate random update vector x
for (int i = 0; i < n; i++) {
x[i] = dist(rng) * 0.5;
}
// compute expected result: H_expected = H +/- x*x'
mju_copy(H_expected.data(), H.data(), n * n);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (flg_plus) {
H_expected[i * n + j] += x[i] * x[j];
} else {
H_expected[i * n + j] -= x[i] * x[j];
}
}
}
// test using dense Cholesky (forward-order: L*L')
mju_copy(L_dense.data(), H.data(), n * n);
// dense factorization
int rank_factor = mju_cholFactor(L_dense.data(), n, 0);
EXPECT_EQ(rank_factor, n) << "Initial factorization failed";
// make copy of x for update (it's modified in-place)
mju_copy(x_copy.data(), x.data(), n);
// apply dense rank-one update
int rank_update =
mju_cholUpdate(L_dense.data(), x_copy.data(), n, flg_plus);
EXPECT_EQ(rank_update, n)
<< "Dense update rank loss for n=" << n << ", flg_plus=" << flg_plus;
// zero out upper triangle (Cholesky only uses lower triangle)
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
L_dense[i * n + j] = 0;
}
}
// reconstruct H from L*L' and compare to expected
mju_mulMatMatT(H_reconstructed.data(), L_dense.data(), L_dense.data(), n,
n, n);
// compare
mjtNum eps = MjTol(1e-8, 5e-4);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
EXPECT_NEAR(H_reconstructed[i * n + j], H_expected[i * n + j], eps)
<< "Dense mismatch at (" << i << "," << j << ") for n=" << n
<< ", flg_plus=" << flg_plus;
}
}
}
}
}
// Test for mju_cholUpdateSparse: sparse rank-one Cholesky update L'*L +/- x*x'
// Uses sparse reverse Cholesky factorization and sparse rank-one update.
TEST_F(EngineUtilSolveTest, MjuCholUpdateSparse) {
MjModelPtr model = LoadModelFromString("<mujoco/>");
MjDataPtr d = MakeData(model);
std::mt19937_64 rng;
rng.seed(123);
std::normal_distribution<double> dist(0, 1);
for (int n : {4, 6, 8}) {
int max_nnz = n * (n + 1) / 2;
vector<mjtNum> H(n * n);
vector<mjtNum> H_lower(n * n);
vector<mjtNum> H_expected(n * n);
vector<mjtNum> sqrtH(n * n);
vector<mjtNum> L_sparse(max_nnz);
vector<int> rownnz(n);
vector<int> rowadr(n);
vector<int> colind(max_nnz);
vector<mjtNum> x(n);
vector<mjtNum> x_sparse(n);
vector<int> x_ind(n);
vector<mjtNum> L_sparse_dense(n * n);
vector<mjtNum> H_sparse_reconstructed(n * n);
for (int flg_plus : {0, 1}) {
// generate random lower-triangular matrix for sqrtH
mju_zero(sqrtH.data(), n * n);
for (int i = 0; i < n; i++) {
for (int j = 0; j <= i; j++) {
sqrtH[n * i + j] = dist(rng);
}
sqrtH[n * i + i] = mju_abs(sqrtH[n * i + i]) + n;
}
// create SPD matrix H = sqrtH * sqrtH' (forward-order: L*L')
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
mjtNum sum = 0;
for (int k = 0; k <= mju_min(i, j); k++) {
sum += sqrtH[n * i + k] * sqrtH[n * j + k];
}
H[n * i + j] = sum;
}
}
// generate random update vector x
for (int i = 0; i < n; i++) {
x[i] = dist(rng) * 0.5;
}
// compute expected result: H_expected = H +/- x*x'
mju_copy(H_expected.data(), H.data(), n * n);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (flg_plus) {
H_expected[i * n + j] += x[i] * x[j];
} else {
H_expected[i * n + j] -= x[i] * x[j];
}
}
}
// copy H to H_lower and zero upper triangle (sparse expects lower only)
mju_copy(H_lower.data(), H.data(), n * n);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
H_lower[i * n + j] = 0;
}
}
// convert lower-triangular H to sparse format
mju_dense2sparse(L_sparse.data(), H_lower.data(), n, n, rownnz.data(),
rowadr.data(), colind.data(), max_nnz);
// prepare sparse update vector (all elements, fully dense)
int x_nnz = n;
mju_copy(x_sparse.data(), x.data(), n);
for (int i = 0; i < n; i++) {
x_ind[i] = i;
}
// sparse Cholesky factorization (reverse-order: L'*L)
mju_cholFactorSparse(L_sparse.data(), n, 0, rownnz.data(), rowadr.data(),
colind.data(), d.get());
// apply sparse rank-one update
int rank_sparse = mju_cholUpdateSparse(
L_sparse.data(), x_sparse.data(), n, flg_plus, rownnz.data(),
rowadr.data(), colind.data(), x_nnz, x_ind.data(), d.get());
EXPECT_EQ(rank_sparse, n)
<< "Sparse update rank loss for n=" << n << ", flg_plus=" << flg_plus;
// reconstruct H from L'*L and compare to expected
mju_sparse2dense(L_sparse_dense.data(), L_sparse.data(), n, n,
rownnz.data(), rowadr.data(), colind.data());
mju_mulMatTMat(H_sparse_reconstructed.data(), L_sparse_dense.data(),
L_sparse_dense.data(), n, n, n);
// compare
mjtNum eps = MjTol(1e-8, 5e-4);
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
EXPECT_NEAR(H_sparse_reconstructed[i * n + j], H_expected[i * n + j],
eps)
<< "Sparse mismatch at (" << i << "," << j << ") for n=" << n
<< ", flg_plus=" << flg_plus;
}
}
}
}
}
// Test that mju_cholFactorSymbolic + mju_cholFactorNumeric produces identical
// results to the reference implementation mju_cholFactorSparse
TEST_F(EngineUtilSolveTest, CholFactorSymbolicNumeric) {
MjModelPtr model = LoadModelFromString("<mujoco/>");
MjDataPtr d = MakeData(model);
// test matrix with fill-in: upper triangle structure
int n = 4;
mjtNum H[16] = {10, 1, 2, 3, 1, 10, 0, 0, 2, 0, 10, 1, 3, 0, 1, 10};
// convert to sparse (lower triangle only)
mjtNum sparseH[16];
int H_rownnz[4], H_rowadr[4], H_colind[16];
mju_dense2sparse(sparseH, H, n, n, H_rownnz, H_rowadr, H_colind, 16);
// transpose for upper triangle (needed by cholFactorSymbolic)
int HT_rownnz[4], HT_rowadr[4], HT_colind[16];
mju_transposeSparse(nullptr, nullptr, n, n, HT_rownnz, HT_rowadr, HT_colind,
nullptr, H_rownnz, H_rowadr, H_colind);
// count fill-in (also computes LT structure)
int L_rownnz[4], L_rowadr[4];
int LT_rownnz[4], LT_rowadr[4];
int nnz = mju_cholFactorSymbolic(nullptr, L_rownnz, L_rowadr, nullptr,
LT_rownnz, LT_rowadr, nullptr, HT_rownnz,
HT_rowadr, HT_colind, n, d.get());
// filling phase: compute L_colind, LT_colind, and LT_pos
int L_colind[16], LT_colind[16], LT_pos[16];
mju_cholFactorSymbolic(L_colind, L_rownnz, L_rowadr, LT_colind, LT_rownnz,
LT_rowadr, LT_pos, HT_rownnz, HT_rowadr, HT_colind, n,
d.get());
// verify LT structure matches what we'd get from a separate transpose
int LT_rownnz_ref[4], LT_rowadr_ref[4], LT_colind_ref[16];
mju_transposeSparse(nullptr, nullptr, n, n, LT_rownnz_ref, LT_rowadr_ref,
LT_colind_ref, nullptr, L_rownnz, L_rowadr, L_colind);
// LT structure should match
EXPECT_THAT(AsVector(LT_rownnz, 4),
ElementsAre(LT_rownnz_ref[0], LT_rownnz_ref[1], LT_rownnz_ref[2],
LT_rownnz_ref[3]));
EXPECT_THAT(AsVector(LT_rowadr, 4),
ElementsAre(LT_rowadr_ref[0], LT_rowadr_ref[1], LT_rowadr_ref[2],
LT_rowadr_ref[3]));
// verify LT_colind and LT_pos match
for (int r = 0; r < n; r++) {
int adr = LT_rowadr[r];
for (int k = 0; k < LT_rownnz[r]; k++) {
int L_idx = LT_pos[adr + k]; // index in L array
// verify L_colind at this position is indeed r
EXPECT_EQ(L_colind[L_idx], r)
<< "LT_pos mismatch at LT[" << r << ", " << k << "]";
}
}
// numeric factorization using new function
mjtNum L_new[16];
int rank_new = mju_cholFactorNumeric(
L_new, n, 1e-10, L_rownnz, L_rowadr, L_colind, LT_rownnz, LT_rowadr,
LT_colind, LT_pos, sparseH, H_rownnz, H_rowadr, H_colind, d.get());
// reference implementation: copy sparse H into L_ref, then factor in-place
mjtNum L_ref[16];
int L_ref_rownnz[4], L_ref_colind[16];
for (int r = 0; r < n; r++) {
int nnz_r = H_rownnz[r];
// count lower triangle elements for this row
int lower_nnz = 0;
for (int i = 0; i < nnz_r; i++) {
if (H_colind[H_rowadr[r] + i] <= r) {
L_ref[L_rowadr[r] + lower_nnz] = sparseH[H_rowadr[r] + i];
L_ref_colind[L_rowadr[r] + lower_nnz] = H_colind[H_rowadr[r] + i];
lower_nnz++;
}
}
L_ref_rownnz[r] = lower_nnz;
}
int rank_ref = mju_cholFactorSparse(L_ref, n, 1e-10, L_ref_rownnz, L_rowadr,
L_ref_colind, d.get());
// compare results
EXPECT_EQ(rank_new, rank_ref);
EXPECT_EQ(rank_new, n);
// compare L values
mjtNum eps = MjTol(1e-10, 5e-5);
for (int i = 0; i < nnz; i++) {
EXPECT_NEAR(L_new[i], L_ref[i], eps) << "mismatch at index " << i;
}
}
// ----------------------------- dense LU --------------------------------------
using DenseLUTest = MujocoTest;
// factor identity, solve recovers b exactly
TEST_F(DenseLUTest, Identity) {
constexpr int n = 4;
mjtNum A[n * n] = {
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
};
int pivot[n];
mjtNum b[n] = {1, 2, 3, 4};
mjtNum x[n];
EXPECT_EQ(mju_factorLU(A, n, pivot), 1);
mju_solveLU(x, A, b, pivot, n);
for (int i = 0; i < n; i++) {
EXPECT_MJTNUM_EQ(x[i], b[i]);
}
}
// 3x3 with known solution
TEST_F(DenseLUTest, SmallKnown) {
constexpr int n = 3;
// A = [2 1 1; 4 3 3; 8 7 9], b = [1; 1; 1]
// solution: x = [1; -1; 0] (verified: A*x = [2-1; 4-3; 8-7] = [1;1;1])
mjtNum A[n * n] = {
2, 1, 1, 4, 3, 3, 8, 7, 9,
};
int pivot[n];
mjtNum b[n] = {1, 1, 1};
mjtNum x[n];
EXPECT_EQ(mju_factorLU(A, n, pivot), 1);
mju_solveLU(x, A, b, pivot, n);
mjtNum eps = MjTol(1e-14, 1e-6);
EXPECT_NEAR(x[0], 1, eps);
EXPECT_NEAR(x[1], -1, eps);
EXPECT_NEAR(x[2], 0, eps);
}
// random SPD matrices: compare LU solve against Cholesky solve
TEST_F(DenseLUTest, RandomSPD) {
std::mt19937_64 rng;
rng.seed(7);
std::normal_distribution<double> dist(0, 1);
for (int n : {4, 8, 16}) {
vector<mjtNum> sqrtH(n * n);
vector<mjtNum> A(n * n);
vector<mjtNum> A_chol(n * n);
vector<mjtNum> b(n);
vector<mjtNum> x_lu(n);
vector<mjtNum> x_chol(n);
vector<int> pivot(n);
// generate random SPD matrix
for (int i = 0; i < n * n; i++) sqrtH[i] = dist(rng);
mju_mulMatTMat(A.data(), sqrtH.data(), sqrtH.data(), n, n, n);
// add diagonal regularizer
for (int i = 0; i < n; i++) A[i * n + i] += n;
// generate random rhs
for (int i = 0; i < n; i++) b[i] = dist(rng);
// solve with Cholesky
mju_copy(A_chol.data(), A.data(), n * n);
int rank = mju_cholFactor(A_chol.data(), n, 0);
EXPECT_EQ(rank, n);
mju_cholSolve(x_chol.data(), A_chol.data(), b.data(), n);
// solve with LU
int ok = mju_factorLU(A.data(), n, pivot.data());
EXPECT_EQ(ok, 1);
mju_solveLU(x_lu.data(), A.data(), b.data(), pivot.data(), n);
// compare
mjtNum eps = MjTol(1e-15, 1e-7);
EXPECT_THAT(AsVector(x_lu.data(), n),
Pointwise(MjNear(eps, eps), AsVector(x_chol.data(), n)));
}
}
// random non-symmetric matrices: verify A*x == b
TEST_F(DenseLUTest, RandomGeneral) {
std::mt19937_64 rng;
rng.seed(42);
std::normal_distribution<double> dist(0, 1);
for (int n : {3, 5, 10, 20}) {
vector<mjtNum> A(n * n);
vector<mjtNum> A_orig(n * n);
vector<mjtNum> b(n);
vector<mjtNum> x(n);
vector<mjtNum> Ax(n);
vector<int> pivot(n);
// random non-symmetric matrix with diagonal dominance
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
A[i * n + j] = dist(rng);
}
A[i * n + i] += 2 * n;
}
mju_copy(A_orig.data(), A.data(), n * n);
// random rhs
for (int i = 0; i < n; i++) b[i] = dist(rng);
// factor and solve
int ok = mju_factorLU(A.data(), n, pivot.data());
EXPECT_EQ(ok, 1);
mju_solveLU(x.data(), A.data(), b.data(), pivot.data(), n);
// verify: A_orig * x == b
mju_mulMatVec(Ax.data(), A_orig.data(), x.data(), n, n);
mjtNum eps = MjTol(1e-14, 1e-5);
EXPECT_THAT(AsVector(Ax.data(), n),
Pointwise(MjNear(eps, eps), AsVector(b.data(), n)));
}
}
// near-singular matrix returns 0
TEST_F(DenseLUTest, Singular) {
constexpr int n = 3;
// all zeros: maximally singular
mjtNum A[n * n] = {0};
int pivot[n];
EXPECT_EQ(mju_factorLU(A, n, pivot), 0);
}
// 6x6 specialization: identical results to the generic version
TEST_F(DenseLUTest, Fixed6MatchesGeneric) {
constexpr int n = 6;
std::mt19937_64 rng;
rng.seed(3);
std::normal_distribution<double> dist(0, 1);
for (int trial = 0; trial < 100; trial++) {
mjtNum A[n * n], A6[n * n], b[n], x[n], x6[n];
int pivot[n], pivot6[n];
// random asymmetric matrix; first trial zeroes the diagonal to force
// pivoting
for (int i = 0; i < n * n; i++) {
A[i] = dist(rng);
}
if (trial == 0) {
for (int i = 0; i < n; i++) {
A[i * n + i] = 0;
}
}
for (int i = 0; i < n; i++) {
b[i] = dist(rng);
}
mju_copy(A6, A, n * n);
// same algorithm; results agree to rounding (not bitwise: the compiler may
// contract the unrolled fixed-size version differently)
int rank = mju_factorLU(A, n, pivot);
int rank6 = mju_factorLU6(A6, pivot6);
ASSERT_EQ(rank, rank6);
if (!rank) {
continue;
}
for (int i = 0; i < n; i++) {
EXPECT_EQ(pivot[i], pivot6[i]);
}
for (int i = 0; i < n * n; i++) {
EXPECT_NEAR(A[i], A6[i], MjTol(1e-13, 1e-4));
}
mju_solveLU(x, A, b, pivot, n);
mju_solveLU6(x6, A6, b, pivot6);
for (int i = 0; i < n; i++) {
EXPECT_NEAR(x[i], x6[i], MjTol(1e-12, 1e-3));
}
}
}
// nonsingular matrix with all-zero diagonal: requires pivoting
TEST_F(DenseLUTest, ZeroDiagonal) {
constexpr int n = 3;
// A = [0 2 1; 1 0 3; 4 1 0], det(A) = 25
mjtNum A[n * n] = {
0, 2, 1, 1, 0, 3, 4, 1, 0,
};
mjtNum A_orig[n * n];
mju_copy(A_orig, A, n * n);
int pivot[n];
mjtNum b[n] = {1, 2, 3};
mjtNum x[n], Ax[n];
EXPECT_EQ(mju_factorLU(A, n, pivot), 1);
mju_solveLU(x, A, b, pivot, n);
// verify A_orig * x == b
mju_mulMatVec(Ax, A_orig, x, n, n);
mjtNum eps = MjTol(1e-14, 1e-6);
EXPECT_THAT(AsVector(Ax, n), Pointwise(MjNear(eps, eps), AsVector(b, n)));
}
} // namespace
} // namespace mujoco