Skip to content

Solve technosphere blocks by Neumann series instead of LU - #236

Merged
TimoDiepers merged 4 commits into
brightway-lca:mainfrom
TimoDiepers:perf/iterative-block-solve
Sep 17, 2026
Merged

TimoDiepers merged 4 commits into
brightway-lca:mainfrom
TimoDiepers:perf/iterative-block-solve

Conversation

@TimoDiepers

@TimoDiepers TimoDiepers commented Sep 16, 2026

Copy link
Copy Markdown
Member

What

Background blocks and the functional unit are now solved by repeated sparse matrix-vector products instead of by factorizing the matrix.

Factorizing an ecoinvent-sized block costs ~4 s, and we only ever ask it a handful of questions. Multiplying costs 20-25 ms per demand column — cheaper than one solve against a factorization we already have, so there is no point at which factorizing wins.

If the iteration cannot handle a block (a zero on the diagonal, or it does not settle on an answer), that block goes to the existing solver instead, so nothing that worked before stops working. BW_TIMEX_NO_ITERATIVE_SOLVER=1 turns the new path off entirely.

Also fixes a slow step in BlockStructure.detect that sorted every matrix entry to find which database groups touch each other: 0.89 s → 0.009 s, same result.

Measured

Premise EV teaching notebook, cold runs, paired before/after:

before after
tlca.lci() 22.9 / 23.7 s 3.9 / 4.0 s
whole notebook 40.3 / 43.3 s 19.0 / 21.2 s

Scores are unchanged to 14 digits.

Tests

622 passed, 7 skipped. New: the iterative solver against a direct solve (single and batched demands, both fallbacks, the opt-out), and a run of the whole pipeline on every solver backend the machine has, with and without the new path, checking scores and inventories match.

A technosphere block is `I - T` for a productive economy, so a
Jacobi-preconditioned Neumann series converges on it, and does so far more
cheaply than an LU factorization: on a premise-sized vintage block (43.6k
rows, 525k nonzeros) ~110 sparse matrix products at 20-25 ms per right-hand
side, against ~4 s for the UMFPACK factorization. There is no break-even
number of right-hand sides - a series solve costs less than UMFPACK's
triangular solve on top of an LU it already has.

`_IterativeBlockSolver` is reached through `allow_iterative=True` on both
solver factories, which `BackgroundSolver` passes by default, and through
`TimexLCA._solve_functional_unit` for the whole expanded technosphere and
both base LCA builds. Blocks it cannot handle - a zero on the diagonal, a
divergent series, or too large a backward error - fall back to the LU
backend, so the fast path is an optimization and never a constraint on
which systems can be solved. `BW_TIMEX_NO_ITERATIVE_SOLVER=1` disables it
process-wide.

Acceptance is a backward error, `||A x - b|| / (||A|| ||x|| + ||b||)`, not
`||r|| / ||b||`: a functional unit of one vehicle pulling 20,000 kWh has a
solution some 1e6 larger than its demand, and scaling by the demand alone
rejects a perfectly good solve.

Also replaces `BlockStructure.detect`'s `np.unique(..., axis=0)` over the
stacked group pairs, which lexsorts a void view of all 2 * nnz values, with
a bincount over encoded pairs: 0.89 s -> 0.009 s on a premise-sized
technosphere, identical pairs.

Measured on the premise EV teaching notebook, paired runs in cold
processes: `lci()` 22.9-23.7 s -> 3.9-4.0 s, whole notebook 40-43 s ->
19-21 s, static score unchanged to 1.3e-14.
Three phase-counting tests broke on CI: they assert how many MKL numeric
factorizations `prepare()` performs, and blocks are now solved by series,
which performs none. They are about the LU path, so they say so - a
`lu_block_solver` fixture sets `BW_TIMEX_NO_ITERATIVE_SOLVER` and asserts
the switch took, rather than letting a count of zero pass for a subject
that never ran. The same pin goes on the grouped-solve phase count, which
compared two zeroes and passed vacuously.

Adds what was missing on the other side: that an ordinary run on a machine
with MKL makes no pardiso calls at all, and that the series and pardiso
agree on the same unit supply.

Fallback tests are now parametrized over every backend the machine has
instead of only its preferred one, so pardiso is exercised as the target of
a fallback (divergent block, zero diagonal, disabled by env var), the
fallback is asserted to be that backend, and a persistent solver's fallback
is asserted never to be pardiso - whose factorization lives in MKL's single
global slot.
@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.74797% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.27%. Comparing base (df9a3b9) to head (c9eca0b).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
bw_timex/solvers.py 96.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #236      +/-   ##
==========================================
+ Coverage   84.86%   85.27%   +0.40%     
==========================================
  Files          16       16              
  Lines        3767     3871     +104     
==========================================
+ Hits         3197     3301     +104     
  Misses        570      570              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TimoDiepers
TimoDiepers merged commit 349690b into brightway-lca:main Sep 17, 2026
9 checks passed
@TimoDiepers
TimoDiepers deleted the perf/iterative-block-solve branch September 17, 2026 12:00
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.

1 participant