// Copyright (C) 2026 Kiyotsugu Arai // SPDX-License-Identifier: LGPL-3.0-or-later /** * @file sparse_lu.hpp * @brief LU factorization for sparse matrices (SparseLU) * * Left-looking column LU factorization + partial pivoting. * Stores L and U in CSC format, preserving the sparse structure. * * The API follows Eigen's SparseLU: * SparseLU lu; * lu.compute(A); * Vector x = lu.solve(b); */ #ifndef SANGI_SPARSE_LU_HPP #define SANGI_SPARSE_LU_HPP #include #include #include #include #include #include #include "../core/sparse_matrix.hpp" #include "../core/vector.hpp" #include "sparse_ordering.hpp" namespace sangi { /** * @brief Direct LU factorization for sparse matrices (CSC storage) * * P * A = L * U (P is a row permutation) * L: unit lower triangular (CSC), U: upper triangular (CSC) * Manages fill-in via left-looking column factorization. */ template class SparseLU { public: /// @param ordering fill-reducing ordering (default AMD). Since solve is permutation-invariant, /// ordering is enabled by default to prevent fill-in blow-up on real problems. explicit SparseLU(SparseOrdering ordering = SparseOrdering::AMD) : ordering_(ordering) {} /// LU-factorizes a sparse matrix void compute(const SparseMatrix& A) { const auto n = static_cast(A.rows()); if (static_cast(A.cols()) != n) throw std::invalid_argument("SparseLU: matrix must be square"); n_ = n; computed_ = false; // Build the fill-reducing symmetric ordering P A Pᵀ as a working matrix. // (ordering is a permutation only and leaves values unchanged; partial pivoting is done on the working matrix) fill_perm_ = (ordering_ == SparseOrdering::AMD && n > 0) ? algorithms::amd_ordering(A) : algorithms::natural_ordering(n); SparseMatrix B(static_cast(n), static_cast(n), SparseStorageFormat::CSC); const bool reordered = (ordering_ == SparseOrdering::AMD && n > 0); if (reordered) { std::vector inv(n); for (std::size_t k = 0; k < n; ++k) inv[fill_perm_[k]] = k; const auto& av = A.csc_values(); const auto& ar = A.csc_row_indices(); const auto& ac = A.csc_col_ptr(); for (std::size_t j = 0; j < n; ++j) { for (auto p = static_cast(ac[j]); p < static_cast(ac[j + 1]); ++p) { const auto i = static_cast(ar[p]); B.set_coeff(static_cast(inv[i]), static_cast(inv[j]), av[p]); } } } // Obtain CSC data (working matrix B when reordered, otherwise A) const auto& a_val = reordered ? B.csc_values() : A.csc_values(); const auto& a_row = reordered ? B.csc_row_indices() : A.csc_row_indices(); const auto& a_col = reordered ? B.csc_col_ptr() : A.csc_col_ptr(); // Permutation arrays perm_.resize(n); // perm_[k] = original row number of pivot row k inv_perm_.resize(n); // inv_perm_[original row] = current row number std::iota(perm_.begin(), perm_.end(), std::size_t(0)); std::iota(inv_perm_.begin(), inv_perm_.end(), std::size_t(0)); // Build L, U column by column (temporary vector of vector) std::vector> L_rows(n), U_rows(n); std::vector> L_vals(n), U_vals(n); // Dense work vectors (reused per column) std::vector x(n, T(0)); std::vector x_nz(n, 0); // nonzero flags (vector is a proxy and cannot swap) std::vector nz_list; // list of nonzero positions nz_list.reserve(n); for (std::size_t k = 0; k < n; ++k) { // 1. Scatter column k of A (original row number → current row number) nz_list.clear(); auto col_start = static_cast(a_col[k]); auto col_end = static_cast(a_col[k + 1]); for (auto p = col_start; p < col_end; ++p) { std::size_t orig_row = static_cast(a_row[p]); std::size_t cur_row = inv_perm_[orig_row]; x[cur_row] = a_val[p]; if (!x_nz[cur_row]) { x_nz[cur_row] = 1; nz_list.push_back(cur_row); } } // 2. Left-looking solve: for j = 0..k-1, if x[j] ≠ 0 update with L(:,j). // A Gilbert-Peierls style topological DFS was implemented (see commit history), // but benchmarks showed that at typical IPM scales of n=50-500 the per-node // DFS overhead (~50 cycles) exceeds the simple loop's skip cost (~2 cycles), // making it 20-30% slower. GP only wins in ultra-sparse cases with n>>1000 // where reach is a few % of n, but in IPM M = A D Aᵀ is not that // sparse, so the simple loop is adopted (details: TODO/PLAN_BENCH_IPM_SPARSE_PHASE2.md). for (std::size_t j = 0; j < k; ++j) { if (!x_nz[j]) continue; if (x[j] == T(0)) continue; // Update with each entry of L(:,j) for (std::size_t q = 0; q < L_rows[j].size(); ++q) { std::size_t i = L_rows[j][q]; T lij = L_vals[j][q]; x[i] -= lij * x[j]; if (!x_nz[i] && x[i] != T(0)) { x_nz[i] = 1; nz_list.push_back(i); } } } // Sort + deduplicate nz_list for pivot search std::sort(nz_list.begin(), nz_list.end()); nz_list.erase(std::unique(nz_list.begin(), nz_list.end()), nz_list.end()); // 3. Partial pivoting: find the maximum value in x[k:n-1] std::size_t pivot = k; T pivot_val = std::abs(x[k]); for (auto r : nz_list) { if (r < k) continue; T v = std::abs(x[r]); if (v > pivot_val) { pivot_val = v; pivot = r; } } if (pivot_val < std::numeric_limits::epsilon() * T(100)) throw std::runtime_error("SparseLU: singular matrix"); // 4. Row swap (pivot ↔ k) if (pivot != k) { std::swap(x[k], x[pivot]); std::swap(x_nz[k], x_nz[pivot]); // Update permutation std::size_t ok = perm_[k], op = perm_[pivot]; std::swap(perm_[k], perm_[pivot]); inv_perm_[ok] = pivot; inv_perm_[op] = k; // Swap rows k and pivot in the existing columns of L (0..k-1) for (std::size_t j = 0; j < k; ++j) { T* vk = nullptr; T* vp = nullptr; for (std::size_t q = 0; q < L_rows[j].size(); ++q) { if (L_rows[j][q] == k) vk = &L_vals[j][q]; if (L_rows[j][q] == pivot) vp = &L_vals[j][q]; } if (vk && vp) { std::swap(*vk, *vp); } else if (vk) { // Present in k but not in pivot // Change the k position to pivot for (std::size_t q = 0; q < L_rows[j].size(); ++q) { if (L_rows[j][q] == k) { L_rows[j][q] = pivot; break; } } } else if (vp) { for (std::size_t q = 0; q < L_rows[j].size(); ++q) { if (L_rows[j][q] == pivot) { L_rows[j][q] = k; break; } } } } } // 5. U column k: the nonzero part of x[0..k] for (auto r : nz_list) { if (r > k) break; if (x[r] != T(0)) { U_rows[k].push_back(r); U_vals[k].push_back(x[r]); } } // Add the diagonal if not already included (numerically near zero) T diag = x[k]; // 6. L column k: the nonzero part of x[k+1..n-1] / diag T inv_diag = T(1) / diag; for (auto r : nz_list) { if (r <= k) continue; if (x[r] != T(0)) { L_rows[k].push_back(r); L_vals[k].push_back(x[r] * inv_diag); } } // 7. Clear the work vectors (nonzero positions only) for (auto r : nz_list) { x[r] = T(0); x_nz[r] = 0; } } // Convert to CSC format build_csc(L_rows, L_vals, L_col_ptr_, L_row_ind_, L_val_); build_csc(U_rows, U_vals, U_col_ptr_, U_row_ind_, U_val_); computed_ = true; } /// Solves the linear system A*x = b [[nodiscard]] Vector solve(const Vector& b) const { if (!computed_) throw std::runtime_error("SparseLU: compute() not called"); if (b.size() != n_) throw std::invalid_argument("SparseLU::solve: dimension mismatch"); // Map the fill-reducing symmetric ordering to working coordinates: c = P b Vector c(n_); for (std::size_t i = 0; i < n_; ++i) c[i] = b[fill_perm_[i]]; // P * c (apply the row-pivot permutation) Vector y(n_); for (std::size_t i = 0; i < n_; ++i) y[i] = c[perm_[i]]; // Forward substitution: L * z = y (L is unit lower triangular, CSC) // Process column k: z[i] -= L(i,k) * z[k] (i > k) for (std::size_t k = 0; k < n_; ++k) { if (y[k] == T(0)) continue; auto start = L_col_ptr_[k]; auto end = L_col_ptr_[k + 1]; for (auto p = start; p < end; ++p) y[L_row_ind_[p]] -= L_val_[p] * y[k]; } // Back substitution: U * x = z (U is upper triangular, CSC) // Process column k from the right: x[k] = (z[k] - sum U(i,k)*x[i]) / U(k,k) for (std::size_t kk = 0; kk < n_; ++kk) { std::size_t k = n_ - 1 - kk; auto start = U_col_ptr_[k]; auto end = U_col_ptr_[k + 1]; // The diagonal of U column k is the last element (row number k) T diag = T(0); for (auto p = start; p < end; ++p) { if (U_row_ind_[p] == k) { diag = U_val_[p]; } else if (U_row_ind_[p] < k) { // nothing — will handle in column processing from right } } y[k] /= diag; // Update with the off-diagonal entries of column k for (auto p = start; p < end; ++p) { if (U_row_ind_[p] < k) y[U_row_ind_[p]] -= U_val_[p] * y[k]; } } // Working coordinates → original coordinates: x[fill_perm_[i]] = y[i] Vector x(n_); for (std::size_t i = 0; i < n_; ++i) x[fill_perm_[i]] = y[i]; return x; } [[nodiscard]] bool computed() const { return computed_; } [[nodiscard]] std::size_t rows() const { return n_; } [[nodiscard]] std::size_t cols() const { return n_; } /// fill-reducing permutation (perm[k] = original index eliminated at step k) [[nodiscard]] const std::vector& permutation() const { return fill_perm_; } private: SparseOrdering ordering_ = SparseOrdering::AMD; std::size_t n_ = 0; bool computed_ = false; std::vector fill_perm_; // fill-reducing symmetric ordering std::vector perm_; std::vector inv_perm_; // Store L (unit lower triangular, diagonal implicitly 1) in CSC std::vector L_col_ptr_; std::vector L_row_ind_; std::vector L_val_; // Store U (upper triangular) in CSC std::vector U_col_ptr_; std::vector U_row_ind_; std::vector U_val_; /// vector of vector → CSC conversion static void build_csc(const std::vector>& rows, const std::vector>& vals, std::vector& col_ptr, std::vector& row_ind, std::vector& val) { const auto n = rows.size(); col_ptr.resize(n + 1); col_ptr[0] = 0; std::size_t total = 0; for (std::size_t j = 0; j < n; ++j) { total += rows[j].size(); col_ptr[j + 1] = total; } row_ind.resize(total); val.resize(total); std::size_t pos = 0; for (std::size_t j = 0; j < n; ++j) { for (std::size_t q = 0; q < rows[j].size(); ++q) { row_ind[pos] = rows[j][q]; val[pos] = vals[j][q]; ++pos; } } } }; } // namespace sangi #endif // SANGI_SPARSE_LU_HPP