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
This commit is contained in:
Yuval Tassa
2026-07-14 08:37:32 -07:00
committed by Copybara-Service
parent b7af32ac87
commit 0afafacfc5
5 changed files with 350 additions and 0 deletions
+79
View File
@@ -834,6 +834,85 @@ void mju_solveLU(mjtNum* restrict x, const mjtNum* LU, const mjtNum* b, const in
}
// 6x6 specialization of mju_factorLU: same algorithm with compile-time size,
// allowing full unrolling; produces identical results
int mju_factorLU6(mjtNum A[36], int pivot[6]) {
for (int k=0; k < 6; k++) {
// initialize pivot
pivot[k] = k;
// find pivot: max absolute value in column k, rows k..n-1
mjtNum maxval = mju_abs(A[k*6+k]);
int maxrow = k;
for (int i=k+1; i < 6; i++) {
mjtNum val = mju_abs(A[i*6+k]);
if (val > maxval) {
maxval = val;
maxrow = i;
}
}
// check singularity
if (maxval < mjMINVAL) {
return 0;
}
// swap rows k and maxrow
if (maxrow != k) {
pivot[k] = maxrow;
for (int j=0; j < 6; j++) {
mjtNum tmp = A[k*6+j];
A[k*6+j] = A[maxrow*6+j];
A[maxrow*6+j] = tmp;
}
}
// compute multipliers and update trailing submatrix
mjtNum diaginv = 1.0 / A[k*6+k];
for (int i=k+1; i < 6; i++) {
A[i*6+k] *= diaginv;
mjtNum Aik = A[i*6+k];
for (int j=k+1; j < 6; j++) {
A[i*6+j] -= Aik * A[k*6+j];
}
}
}
return 1;
}
// solve A*x = b given 6x6 LU factorization from mju_factorLU6
void mju_solveLU6(mjtNum x[6], const mjtNum LU[36], const mjtNum b[6], const int pivot[6]) {
for (int i=0; i < 6; i++) {
x[i] = b[i];
}
// apply row permutation and forward substitution: solve L*y = P*b
for (int i=0; i < 6; i++) {
// apply pivot swap
if (pivot[i] != i) {
mjtNum tmp = x[i];
x[i] = x[pivot[i]];
x[pivot[i]] = tmp;
}
// subtract known terms
for (int j=0; j < i; j++) {
x[i] -= LU[i*6+j] * x[j];
}
}
// back substitution: solve U*x = y
for (int i=6-1; i >= 0; i--) {
for (int j=i+1; j < 6; j++) {
x[i] -= LU[i*6+j] * x[j];
}
x[i] /= LU[i*6+i];
}
}
//------------------------------ sparse LU factorization -------------------------------------------
// sparse reverse-order LU factorization, no fill-in (assuming tree topology)
+6
View File
@@ -104,6 +104,12 @@ MJAPI int mju_factorLU(mjtNum* A, int n, int* pivot);
// solve A*x = b given LU factorization of A, LU and pivot are output of mju_factorLU
MJAPI void mju_solveLU(mjtNum* x, const mjtNum* LU, const mjtNum* b, const int* pivot, int n);
// 6x6 specialization of mju_factorLU (identical results, allows full unrolling)
MJAPI int mju_factorLU6(mjtNum A[36], int pivot[6]);
// solve A*x = b given 6x6 LU factorization from mju_factorLU6
MJAPI void mju_solveLU6(mjtNum x[6], const mjtNum LU[36], const mjtNum b[6], const int pivot[6]);
// sparse reverse-order LU factorization, assume tree topology (only dofs in index, if given)
// LU = L + U; original = (U+I) * L; scratch is size n
void mju_factorLUSparse(mjtNum *LU, int n, int* scratch,
+6
View File
@@ -67,6 +67,12 @@ mujoco_test(
ADDITIONAL_LINK_LIBRARIES benchmark::benchmark absl::core_headers
)
mujoco_test(
lu_benchmark_test
MAIN_TARGET benchmark::benchmark_main
ADDITIONAL_LINK_LIBRARIES benchmark::benchmark absl::core_headers
)
mujoco_test(
crb_benchmark_test
MAIN_TARGET benchmark::benchmark_main
+188
View File
@@ -0,0 +1,188 @@
// Copyright 2026 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.
// A benchmark comparing generic (runtime-sized) and fixed-size dense LU
// factorization and solve at n = 6, with dense Cholesky as a reference.
// The 6x6 case is the free-body block in the implicitfast integrator.
#include <random>
#include <vector>
#include "benchmark/benchmark.h"
#include <absl/base/attributes.h>
#include <mujoco/mujoco.h>
#include "src/engine/engine_util_blas.h"
#include "src/engine/engine_util_solve.h"
namespace mujoco {
namespace {
constexpr int kN = 6;
constexpr int kNumMat = 256; // cycle through matrices to avoid cache pinning
// generate matrices with the structure of the free-body block M - h*D + h*G:
// symmetric positive definite plus a scaled antisymmetric part
struct LUData {
std::vector<mjtNum> mats; // kNumMat x 36
std::vector<mjtNum> rhs; // kNumMat x 6
LUData() : mats(kNumMat * kN * kN), rhs(kNumMat * kN) {
std::mt19937_64 rng(42);
std::normal_distribution<double> dist(0, 1);
for (int m = 0; m < kNumMat; m++) {
mjtNum* A = mats.data() + m * kN * kN;
mjtNum sqrtH[kN * kN];
for (int i = 0; i < kN * kN; i++) {
sqrtH[i] = dist(rng);
}
// A = sqrtH' * sqrtH + n*I: SPD
mju_mulMatTMat(A, sqrtH, sqrtH, kN, kN, kN);
for (int i = 0; i < kN; i++) {
A[i * kN + i] += kN;
}
// add antisymmetric part (gyroscopic-like), scaled to ~10% of diagonal
for (int r = 0; r < kN; r++) {
for (int c = r + 1; c < kN; c++) {
mjtNum g = 0.6 * dist(rng);
A[r * kN + c] += g;
A[c * kN + r] -= g;
}
}
for (int i = 0; i < kN; i++) {
rhs[m * kN + i] = dist(rng);
}
}
}
};
LUData& GetData() {
static LUData* data = new LUData();
return *data;
}
enum class Variant {
kGeneric, // mju_factorLU / mju_solveLU with n = 6
kFixed, // mju_factorLU6 / mju_solveLU6
kCholesky // mju_cholFactor / mju_cholSolve (symmetric part; reference)
};
// factor only (includes the copy, identical across variants)
ABSL_ATTRIBUTE_NO_TAIL_CALL static void FactorBenchmark(benchmark::State& state,
Variant variant) {
LUData& data = GetData();
mjtNum A[kN * kN];
int pivot[kN];
int m = 0;
for (auto s : state) {
mju_copy(A, data.mats.data() + m * kN * kN, kN * kN);
m = (m + 1) % kNumMat;
int rank;
switch (variant) {
case Variant::kGeneric:
rank = mju_factorLU(A, kN, pivot);
break;
case Variant::kFixed:
rank = mju_factorLU6(A, pivot);
break;
case Variant::kCholesky:
rank = mju_cholFactor(A, kN, 0);
break;
}
benchmark::DoNotOptimize(rank);
benchmark::DoNotOptimize(A);
}
state.counters["factor/s"] =
benchmark::Counter(state.iterations(), benchmark::Counter::kIsRate);
}
// factor and solve
ABSL_ATTRIBUTE_NO_TAIL_CALL static void FactorSolveBenchmark(
benchmark::State& state, Variant variant) {
LUData& data = GetData();
mjtNum A[kN * kN];
mjtNum x[kN];
int pivot[kN];
int m = 0;
for (auto s : state) {
mju_copy(A, data.mats.data() + m * kN * kN, kN * kN);
const mjtNum* b = data.rhs.data() + m * kN;
m = (m + 1) % kNumMat;
switch (variant) {
case Variant::kGeneric:
mju_factorLU(A, kN, pivot);
mju_solveLU(x, A, b, pivot, kN);
break;
case Variant::kFixed:
mju_factorLU6(A, pivot);
mju_solveLU6(x, A, b, pivot);
break;
case Variant::kCholesky:
mju_cholFactor(A, kN, 0);
mju_cholSolve(x, A, b, kN);
break;
}
benchmark::DoNotOptimize(x);
}
state.counters["solve/s"] =
benchmark::Counter(state.iterations(), benchmark::Counter::kIsRate);
}
void BM_factorLU_generic6(benchmark::State& state) {
FactorBenchmark(state, Variant::kGeneric);
}
BENCHMARK(BM_factorLU_generic6);
void BM_factorLU_fixed6(benchmark::State& state) {
FactorBenchmark(state, Variant::kFixed);
}
BENCHMARK(BM_factorLU_fixed6);
void BM_factorChol6(benchmark::State& state) {
FactorBenchmark(state, Variant::kCholesky);
}
BENCHMARK(BM_factorChol6);
void BM_factorSolveLU_generic6(benchmark::State& state) {
FactorSolveBenchmark(state, Variant::kGeneric);
}
BENCHMARK(BM_factorSolveLU_generic6);
void BM_factorSolveLU_fixed6(benchmark::State& state) {
FactorSolveBenchmark(state, Variant::kFixed);
}
BENCHMARK(BM_factorSolveLU_fixed6);
void BM_factorSolveChol6(benchmark::State& state) {
FactorSolveBenchmark(state, Variant::kCholesky);
}
BENCHMARK(BM_factorSolveChol6);
} // namespace
} // namespace mujoco
int main(int argc, char** argv) {
benchmark::Initialize(&argc, argv);
benchmark::RunSpecifiedBenchmarks();
return 0;
}
+71
View File
@@ -1235,5 +1235,76 @@ TEST_F(DenseLUTest, Singular) {
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