Replace the flex metric factorization with a block preconditioner

Every step, the flex block of the implicit effective metric M + K was
factorized by sparse Cholesky, because K depends on the configuration. On
model/flex/bag.xml, added here, that is roughly half the step, against a
comparable share for the constraint solve it exists to accelerate.

Keep only the metric's per-vertex 3x3 diagonal blocks, prefactored. Neither
consumer needs the exact inverse: the CG constraint solver only wants a
preconditioner, and qacc_smooth can come from an iterative solve using those
blocks. They are O(n) to build and to apply, but weaker, so CG runs about twice
the iterations and qacc_smooth becomes an iteration rather than a direct solve.
Net, the bag model steps roughly twice as fast.

The preconditioner, by metric state. Inactive, meaning no flex elasticity or an
explicit integrator: M^-1, unchanged. Bending only (nefmK == 0): M^-1 plus the
exact constant bending factor from mj_setConst on the dofs it covers,
unchanged; that factor is built at model compile time and costs nothing per
step. Per-step stiffness: M^-1 plus the 3x3 blocks, where before it was a
per-step sparse Cholesky, or, when M couples across the flex block, an inner
PCG of up to 50 iterations run once per outer CG iteration.

Only models carrying per-step stretch stiffness change in wall-clock. Both
ponchos hold their timing and take slightly fewer CG iterations than before,
because the preconditioner is now symmetric: it applies M^-1 and the covered
blocks to disjoint sets of dofs, where previously the two overlapped and the
operator was not symmetric, which PCG requires.

mjd_effSolve is the accurate solve of (M + K)x = b; what used to carry that
name only preconditions and is now mjd_effPrec. Its CG guarded the division by
pAp with mjMINVAL, an absolute floor on a quantity that scales with the square
of the right-hand side, so a small b aborted the solve while the curvature was
healthy: four flex models were quietly left short of tolerance. For an SPD
metric the guard is positivity, and with that the same solves converge. The qacc_smooth call site in
mj_fwdAcceleration is textually unchanged but now reaches the iterative solve,
which converges on opt.tolerance rather than a hardcoded threshold, floored in
mjUSESINGLE builds where the squared target is unreachable in float. Reaching
the iteration cap names the ill-conditioned flex stiffness and then reports it
through mjWARN_INERTIA, rather than returning an under-converged result.
Covered dofs are located by walking the covered rows of the stiffness matrix,
as they need not be 3-aligned from dof 0: any joint declared before a flexcomp
shifts them.

mjData.efm_L_rownnz, efm_L_rowadr and efm_L_colind described the sparsity of
the deleted factorization and are removed: left NULL with nonzero mjxmacro
extents they made the Python bindings hand back uninitialized arrays.
efm_active loses the value 2 for the same reason, nothing selects a solve path
on preconditioner exactness any more. Both are recorded under breaking changes.

model/flex/bag.xml is added because no shipped model carried per-step stretch
stiffness. The ponchos are bending-only and trampoline.xml uses an explicit
integrator, so the metric never activates there. It is excluded from
WriteReadCompareTest: stretch stiffness amplifies rest geometry that XML rounds
on save.
This commit is contained in:
Alessio
2026-07-29 14:19:58 +01:00
parent d400914e97
commit 55d13aec5f
16 changed files with 4914 additions and 485 deletions
+5 -29
View File
@@ -5737,7 +5737,7 @@ STRUCTS: Mapping[str, StructDecl] = dict([
StructFieldDecl(
name='efm_active',
type=ValueType(name='int'),
doc='implicit effective metric M+K: 0 inactive, 1 active, 2 active + preconditioner exact', # pylint: disable=line-too-long
doc='implicit effective metric M+K is active (see mjd_effBuild)', # pylint: disable=line-too-long
),
StructFieldDecl(
name='nefmK',
@@ -5747,12 +5747,12 @@ STRUCTS: Mapping[str, StructDecl] = dict([
StructFieldDecl(
name='nefmdof',
type=ValueType(name='int'),
doc='number of rows in effective-metric factor',
doc='number of 3x3 blocks in the effective-metric preconditioner', # pylint: disable=line-too-long
),
StructFieldDecl(
name='nefmL',
type=ValueType(name='int'),
doc='number of non-zeros in the effective-metric factor',
doc='size of the effective-metric block storage (9*nefmdof)',
),
StructFieldDecl(
name='nY',
@@ -7038,39 +7038,15 @@ STRUCTS: Mapping[str, StructDecl] = dict([
type=PointerType(
inner_type=ValueType(name='int'),
),
doc='factor row -> dof address',
doc='block k -> dof address of its vertex triple',
array_extent=('nefmdof',),
),
StructFieldDecl(
name='efm_L_rownnz',
type=PointerType(
inner_type=ValueType(name='int'),
),
doc='factor row nonzeros',
array_extent=('nefmdof',),
),
StructFieldDecl(
name='efm_L_rowadr',
type=PointerType(
inner_type=ValueType(name='int'),
),
doc='factor row addresses',
array_extent=('nefmdof',),
),
StructFieldDecl(
name='efm_L_colind',
type=PointerType(
inner_type=ValueType(name='int'),
),
doc='factor column indices',
array_extent=('nefmL',),
),
StructFieldDecl(
name='efm_L',
type=PointerType(
inner_type=ValueType(name='mjtNum'),
),
doc='Cholesky factor of diag(M)+K, covered dofs',
doc='factored 3x3 diagonal blocks of M+K',
array_extent=('nefmL',),
),
StructFieldDecl(