@@ -107,6 +107,90 @@ static auto solve_least_squares_spqr(const Eigen::SparseMatrix<double> &A, const
107107 cholmod_l_finish (&cc);
108108 return status;
109109}
110+
111+ // Rank-revealing reduction of a HOMOGENEOUS constraint matrix C (rows define C x = 0) via SuiteSparseQR
112+ // -- a sparse, multithreaded replacement for the densifying LAPACK dgeqp3 path. We factorize the TALL
113+ // matrix C^T (N x P) with rank detection and column pivoting: the rank-revealing pivot deflates the
114+ // dependent columns of C^T (= dependent rows of C) to the end of the permutation E, so E[0..rank-1] are
115+ // the independent rows of C. C_red is then those ORIGINAL (sparse) rows of C -- NOT the R factor, which
116+ // is densely filled for these constraints. R (only rank x P here) and Q are discarded.
117+ // Only the homogeneous case is handled (every invariance subset, and the merged matrix when no
118+ // FC2FIX/FC3FIX value is imposed); inhomogeneous d != 0 and any SuiteSparseQR failure fall back to
119+ // dgeqp3. Returns 0 on success, 1 on failure.
120+ static auto get_independent_rows_spqr (const Eigen::SparseMatrix<double > &C, const int verbosity,
121+ const double tolerance, Eigen::SparseMatrix<double > &C_red, int &r) -> int
122+ {
123+ const SuiteSparse_long P = C.rows ();
124+ const SuiteSparse_long Ncol = C.cols ();
125+
126+ Eigen::SparseMatrix<double > Ct = C.transpose (); // N x P; its columns are the rows of C
127+ Ct.makeCompressed ();
128+ const SuiteSparse_long nnz = Ct.nonZeros ();
129+
130+ // Widen Eigen's 32-bit CSC indices to the 64-bit indices the long CHOLMOD interface expects
131+ // (same pattern as solve_least_squares_spqr). C^T has P columns, so outer has P+1 entries.
132+ std::vector<SuiteSparse_long> outer (P + 1 ), inner (nnz);
133+ for (SuiteSparse_long j = 0 ; j <= P; ++j) outer[j] = Ct.outerIndexPtr ()[j];
134+ for (SuiteSparse_long k = 0 ; k < nnz; ++k) inner[k] = Ct.innerIndexPtr ()[k];
135+
136+ cholmod_common cc;
137+ cholmod_l_start (&cc);
138+
139+ cholmod_sparse A; // A = C^T (N x P)
140+ std::memset (&A, 0 , sizeof (A));
141+ A.nrow = Ncol;
142+ A.ncol = P;
143+ A.nzmax = nnz;
144+ A.p = outer.data ();
145+ A.i = inner.data ();
146+ A.x = const_cast <double *>(Ct.valuePtr ());
147+ A.stype = 0 ;
148+ A.itype = CHOLMOD_LONG ;
149+ A.xtype = CHOLMOD_REAL ;
150+ A.dtype = CHOLMOD_DOUBLE ;
151+ A.sorted = 1 ;
152+ A.packed = 1 ;
153+
154+ // Map the caller's auto sentinel (tol < 0, i.e. rank_tolerance_auto = -1) to SuiteSparseQR's default
155+ // tolerance SPQR_DEFAULT_TOL (= -2). Passing -1 literally would be SPQR_NO_TOL (rank detection off).
156+ // An explicit positive tolerance is passed through. NOTE: SuiteSparseQR thresholds column 2-norms
157+ // whereas dgeqp3 thresholds R-diagonals, so the numerical rank may differ by a few rows on a
158+ // borderline (badly-scaled) constraint set.
159+ const double spqr_tol = (tolerance < 0.0 ) ? SPQR_DEFAULT_TOL : tolerance;
160+
161+ cholmod_sparse *R = nullptr ;
162+ SuiteSparse_long *E = nullptr ; // size P column permutation of C^T = row permutation of C
163+ const SuiteSparse_long rank = SuiteSparseQR_C (SPQR_ORDERING_DEFAULT , spqr_tol, /* econ=*/ 0 , /* getCTX=*/ 0 ,
164+ &A, /* Bsparse=*/ nullptr , /* Bdense=*/ nullptr ,
165+ /* Zsparse=*/ nullptr , /* Zdense=*/ nullptr , &R, &E,
166+ /* H=*/ nullptr , /* HPinv=*/ nullptr , /* HTau=*/ nullptr , &cc);
167+
168+ int status = 1 ;
169+ if (rank >= 0 && cc.status == CHOLMOD_OK ) {
170+ r = static_cast <int >(rank);
171+ // E[0..rank-1] are the independent columns of C^T = independent rows of C (E == NULL => identity).
172+ // Build C_red from those ORIGINAL sparse rows of C.
173+ Eigen::SparseMatrix<double , Eigen::RowMajor> Crow = C;
174+ std::vector<Eigen::Triplet<double >> tri;
175+ tri.reserve (static_cast <size_t >(C.nonZeros ()));
176+ for (int k = 0 ; k < r; ++k) {
177+ const SuiteSparse_long orig_row = E ? E[k] : k;
178+ for (Eigen::SparseMatrix<double , Eigen::RowMajor>::InnerIterator it (Crow, orig_row); it; ++it) {
179+ tri.emplace_back (k, static_cast <int >(it.col ()), it.value ());
180+ }
181+ }
182+ C_red.resize (r, static_cast <int >(Ncol));
183+ C_red.setFromTriplets (tri.begin (), tri.end ());
184+ C_red.makeCompressed ();
185+ status = 0 ;
186+ LOG_IF (verbosity, 1 , " SuiteSparseQR reduction: rank " , r, " of " , static_cast <int >(P),
187+ " rows, C_red nnz=" , C_red.nonZeros (), " .\n " );
188+ }
189+ if (R) cholmod_l_free_sparse (&R, &cc);
190+ if (E) cholmod_l_free (P, sizeof (SuiteSparse_long), E, &cc);
191+ cholmod_l_finish (&cc);
192+ return status;
193+ }
110194#endif
111195
112196
@@ -289,6 +373,20 @@ auto get_independent_rows_lapack_sparse(const Eigen::SparseMatrix<double> &C_spa
289373
290374 LOG_IF (verbosity, 1 , " P = " , P, " , N = " , N, " , nnz = " , C_sparse.nonZeros (), " .\n " );
291375
376+ #ifdef USE_SUITESPARSE_BACKEND
377+ // SuiteSparseQR rank-revealing reduction for HOMOGENEOUS constraints (every invariance subset, and
378+ // the merged matrix when no FC2FIX/FC3FIX value is imposed): sparse + multithreaded, avoiding the
379+ // dense P x N densification and dgeqp3 below. Inhomogeneous d (d != 0, from FC2FIX/FC3FIX) and any
380+ // SuiteSparseQR failure fall through to the dgeqp3 path.
381+ if (dvec.isZero (0 )) {
382+ if (get_independent_rows_spqr (C_sparse, verbosity, tolerance, C_red, r) == 0 ) {
383+ d_red = Eigen::VectorXd::Zero (r);
384+ return 0 ;
385+ }
386+ LOG_ERR_IF (verbosity, 0 , " SuiteSparseQR reduction failed; falling back to dgeqp3.\n " );
387+ }
388+ #endif
389+
292390 // 1) Copy sparse→dense column-major buffer C_mat (size P×N)
293391 std::vector<double > C_mat (static_cast <size_t >(P_i) * static_cast <size_t >(N_i), 0.0 );
294392 for (int col = 0 ; col < N; ++col) {
0 commit comments