Skip to content

perf(linear): compute JacobianFactor::gradient() directly, skip HessianFactor conversion#2510

Open
jashshah999 wants to merge 2 commits intoborglab:developfrom
jashshah999:perf/jacobian-gradient-direct
Open

perf(linear): compute JacobianFactor::gradient() directly, skip HessianFactor conversion#2510
jashshah999 wants to merge 2 commits intoborglab:developfrom
jashshah999:perf/jacobian-gradient-direct

Conversation

@jashshah999
Copy link
Copy Markdown
Contributor

Problem

JacobianFactor::gradient(Key, VectorValues) converts the entire factor to a HessianFactor to compute a single gradient entry (flagged by an existing TODO at line 881):

Vector JacobianFactor::gradient(Key key, const VectorValues& x) const {
  // TODO: optimize it for JacobianFactor without converting to a HessianFactor
  HessianFactor hessian(*this);
  return hessian.gradient(key, x);
}

The HessianFactor constructor computes A'A which is O(n*m^2) for an n-row, m-column Jacobian. This is unnecessary when only one key's gradient is needed.

Fix

Compute the gradient directly:

  1. Evaluate unwhitened residual e = A*x - b by iterating over Jacobian blocks — O(n*m)
  2. Apply noise model: e = Sigma^{-1} * e (double whiten)
  3. Return A_k' * e for the requested key — O(n*d_k)

Total cost: O(nm) vs O(nm^2) for the old path. No heap allocation for the HessianFactor.

Tests

Two new tests in testJacobianFactor.cpp:

  • gradient: isotropic noise model, verifies numerical equivalence with the HessianFactor path
  • gradient_no_noise: no noise model (unit covariance), same verification

…sianFactor

JacobianFactor::gradient(Key, VectorValues) was converting the entire
factor to a HessianFactor (O(n*m^2) for A'A computation) just to read
one gradient entry. This was flagged by a TODO in the code.

The gradient for key k is A_k' * Sigma^{-1} * (A*x - b), which can be
computed in O(n*m) by evaluating the residual directly and multiplying
by the transpose of the requested block. This avoids the HessianFactor
allocation and the full Hessian matrix product.

Adds two tests verifying numerical equivalence with the HessianFactor
path, with and without a noise model.
@dellaert dellaert requested a review from Copilot April 27, 2026 20:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR optimizes JacobianFactor::gradient(Key, VectorValues) by computing a single-key gradient directly from Jacobian blocks, avoiding conversion to HessianFactor and its AᵀA construction cost.

Changes:

  • Reimplemented JacobianFactor::gradient() to compute the residual and project onto a requested key’s Jacobian block.
  • Added unit tests comparing the new path to the existing HessianFactor-based gradient for isotropic and no-noise cases.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
gtsam/linear/JacobianFactor.cpp Replaces HessianFactor conversion with direct residual/gradient computation.
gtsam/linear/tests/testJacobianFactor.cpp Adds tests validating gradient equivalence against HessianFactor.

Comment thread gtsam/linear/JacobianFactor.cpp Outdated
Comment thread gtsam/linear/JacobianFactor.cpp Outdated
}

// Return A_k' * Sigma^{-1} * (A*x - b) for the requested key
const Factor::const_iterator it = find(key);
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If key is not present in this factor, find(key) may return end(), making it - begin() equal to size() and causing out-of-bounds access on Ab_. The previous implementation delegated to HessianFactor::gradient, which likely had defined behavior for missing keys. Add an explicit check for it == end() and match the prior behavior (e.g., return an empty vector or throw the same exception type/message as before).

Suggested change
const Factor::const_iterator it = find(key);
const Factor::const_iterator it = find(key);
if (it == end())
throw std::out_of_range("JacobianFactor::gradient: requested key is not present in this factor");

Copilot uses AI. Check for mistakes.
Comment on lines +519 to +523
TEST(JacobianFactor, gradient)
{
// Build a factor: || [-I; I] [x1; x2] - b ||^2 with isotropic noise
const double sigma = 0.1;
SharedDiagonal noise = noiseModel::Isotropic::Sigma(2, sigma);
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added tests cover isotropic noise and the no-noise case, but they do not cover a non-diagonal Gaussian noise model where whitenInPlace() uses a non-symmetric sqrt-information factor. Since the new implementation’s correctness depends on applying RᵀR (not ), add a test using a full covariance / general Gaussian noise model (e.g., a 2x2 non-diagonal covariance) and compare to the HessianFactor path; this will catch regressions in the noise-weighted gradient computation.

Copilot uses AI. Check for mistakes.
EXPECT(assert_equal(expected2, actual2, 1e-9));
}

/* ************************************************************************* */
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The added tests cover isotropic noise and the no-noise case, but they do not cover a non-diagonal Gaussian noise model where whitenInPlace() uses a non-symmetric sqrt-information factor. Since the new implementation’s correctness depends on applying RᵀR (not ), add a test using a full covariance / general Gaussian noise model (e.g., a 2x2 non-diagonal covariance) and compare to the HessianFactor path; this will catch regressions in the noise-weighted gradient computation.

Suggested change
/* ************************************************************************* */
/* ************************************************************************* */
TEST(JacobianFactor, gradient_full_gaussian_noise)
{
// Test gradient with a full non-diagonal Gaussian noise model. This exercises
// whitening with a non-symmetric sqrt-information factor, where correctness
// depends on applying R'R rather than R^2.
Matrix A1 = (Matrix(2, 2) << 1.0, 2.0, -0.5, 3.0).finished();
Matrix A2 = (Matrix(2, 2) << -4.0, 0.25, 1.5, 2.0).finished();
Vector b = Vector2(0.3, -1.2);
Matrix covariance = (Matrix(2, 2) << 2.0, 0.3, 0.3, 1.0).finished();
auto noise = noiseModel::Gaussian::Covariance(covariance);
JacobianFactor lf(1, A1, 2, A2, b, noise);
VectorValues x;
x.insert(1, Vector2(0.5, -1.0));
x.insert(2, Vector2(2.0, 1.5));
HessianFactor hf(lf);
EXPECT(assert_equal(hf.gradient(1, x), lf.gradient(1, x), 1e-9));
EXPECT(assert_equal(hf.gradient(2, x), lf.gradient(2, x), 1e-9));
}
/* ************************************************************************* */

Copilot uses AI. Check for mistakes.
The previous implementation applied whitenInPlace twice, computing
R*R*e instead of R^T*R*e. These differ for non-symmetric R (any
non-diagonal covariance). Fix: whiten both A_k and e once, then
compute A_k_w^T * e_w = A_k^T * R^T * R * e.

Also added bounds check for missing key and a test with non-diagonal
covariance.
@jashshah999
Copy link
Copy Markdown
Contributor Author

Pushed a fix for the noise model handling. The previous version applied whitenInPlace twice, computing R*R*e instead of R^T*R*e. These differ when R is non-symmetric (any non-diagonal covariance). Now whitens both A_k and e once: (R*A_k)^T * (R*e) = A_k^T * R^T*R * e.

Also added a bounds check for missing keys and a test with non-diagonal (Gaussian) covariance that would have caught this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants