ANYsolver 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anysolver/__init__.py +631 -0
- anysolver/anystructure_fem_mode.py +942 -0
- anysolver/arc_length.py +758 -0
- anysolver/assembly.py +950 -0
- anysolver/baselines.py +303 -0
- anysolver/beam_shell_verification.py +4276 -0
- anysolver/beam_validity.py +110 -0
- anysolver/benchmarks.py +495 -0
- anysolver/boundary.py +476 -0
- anysolver/buckling.py +442 -0
- anysolver/buckling_validity.py +91 -0
- anysolver/capacity_workflow.py +350 -0
- anysolver/cases.py +173 -0
- anysolver/composite_strip_verification.py +297 -0
- anysolver/contact.py +3461 -0
- anysolver/corotational.py +371 -0
- anysolver/cylinder_benchmarks.py +364 -0
- anysolver/dynamics.py +723 -0
- anysolver/element_qualification.py +432 -0
- anysolver/elements.py +2724 -0
- anysolver/external_references.py +369 -0
- anysolver/fe_core.py +327 -0
- anysolver/fracture.py +551 -0
- anysolver/imperfections.py +390 -0
- anysolver/jit_compiler.py +108 -0
- anysolver/kernel_warmup.py +180 -0
- anysolver/linalg.py +760 -0
- anysolver/mass_properties.py +179 -0
- anysolver/material_curves.py +231 -0
- anysolver/matrix_assembly.py +558 -0
- anysolver/mesh_gen.py +1065 -0
- anysolver/mesh_load_bc_verification.py +609 -0
- anysolver/modal.py +282 -0
- anysolver/nonlinear.py +300 -0
- anysolver/nonlinear_performance.py +920 -0
- anysolver/nonlinear_performance_batch_b.py +592 -0
- anysolver/nonlinear_performance_batch_c.py +506 -0
- anysolver/nonlinear_performance_bootstrap.py +120 -0
- anysolver/nonlinear_reduced_assembly.py +760 -0
- anysolver/nonlinear_static.py +1518 -0
- anysolver/plasticity.py +419 -0
- anysolver/plasticity_qualification.py +314 -0
- anysolver/production_readiness.py +304 -0
- anysolver/quality_control.py +1497 -0
- anysolver/recovery.py +505 -0
- anysolver/recovery_policy.py +205 -0
- anysolver/reference_cases.py +425 -0
- anysolver/results.py +503 -0
- anysolver/runtime.py +9030 -0
- anysolver/s4_validity.py +326 -0
- anysolver/sesam_fem/__init__.py +55 -0
- anysolver/sesam_fem/__main__.py +80 -0
- anysolver/sesam_fem/diagnostics.py +65 -0
- anysolver/sesam_fem/document.py +814 -0
- anysolver/sesam_fem/exporter.py +84 -0
- anysolver/sesam_fem/importer.py +365 -0
- anysolver/sesam_fem/records.py +257 -0
- anysolver/sesam_fem/schema.py +107 -0
- anysolver/sesam_fem/sif_importer.py +397 -0
- anysolver/sesam_fem/validation.py +62 -0
- anysolver/shell_benchmarks.py +367 -0
- anysolver/test_cases.py +610 -0
- anysolver/validation.py +416 -0
- anysolver/vectorized_nonlinear.py +334 -0
- anysolver/vectorized_stiffness.py +571 -0
- anysolver-0.1.0.dist-info/METADATA +165 -0
- anysolver-0.1.0.dist-info/RECORD +70 -0
- anysolver-0.1.0.dist-info/WHEEL +5 -0
- anysolver-0.1.0.dist-info/licenses/LICENSE +674 -0
- anysolver-0.1.0.dist-info/top_level.txt +1 -0
anysolver/linalg.py
ADDED
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
"""Sparse linear algebra backends for FE analyses.
|
|
2
|
+
|
|
3
|
+
SciPy SuperLU is the universal fallback. ``AutoSparseSolverBackend`` can select
|
|
4
|
+
PyPardiso for sufficiently large systems, with matrix-class-aware mtypes,
|
|
5
|
+
pattern-slot symbolic reuse, bounded cache size, and general-matrix fallback.
|
|
6
|
+
All paths report backend, ordering, timings, reuse, and failure diagnostics
|
|
7
|
+
through the same factorization-handle API.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ctypes
|
|
13
|
+
import importlib.util
|
|
14
|
+
import site
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
import weakref
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
from scipy import sparse
|
|
29
|
+
from scipy.sparse import SparseEfficiencyWarning
|
|
30
|
+
from scipy.sparse.linalg import LinearOperator, splu
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
_HAS_PYPARDISO = importlib.util.find_spec("pypardiso") is not None
|
|
34
|
+
except (ImportError, ModuleNotFoundError, ValueError):
|
|
35
|
+
_HAS_PYPARDISO = False
|
|
36
|
+
|
|
37
|
+
_PYPARDISO_SOLVER_CLASS: Any = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _new_pypardiso_solver(*, mtype: int) -> Any:
|
|
41
|
+
"""Construct PyPardiso only after the backend has actually been selected."""
|
|
42
|
+
|
|
43
|
+
global _PYPARDISO_SOLVER_CLASS
|
|
44
|
+
if not _HAS_PYPARDISO:
|
|
45
|
+
raise ImportError("pypardiso is not installed")
|
|
46
|
+
if _PYPARDISO_SOLVER_CLASS is None:
|
|
47
|
+
from pypardiso import PyPardisoSolver as solver_class
|
|
48
|
+
|
|
49
|
+
_PYPARDISO_SOLVER_CLASS = solver_class
|
|
50
|
+
return _PYPARDISO_SOLVER_CLASS(mtype=int(mtype))
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
from numba import njit, prange
|
|
54
|
+
_HAS_NUMBA = True
|
|
55
|
+
except ImportError:
|
|
56
|
+
def njit(*args, **kwargs):
|
|
57
|
+
def wrapper(func):
|
|
58
|
+
return func
|
|
59
|
+
if len(args) == 1 and callable(args[0]):
|
|
60
|
+
return args[0]
|
|
61
|
+
return wrapper
|
|
62
|
+
prange = range
|
|
63
|
+
_HAS_NUMBA = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _env_int(name: str, default: int) -> int:
|
|
67
|
+
value = os.environ.get(name)
|
|
68
|
+
if value is None or value == "":
|
|
69
|
+
return int(default)
|
|
70
|
+
try:
|
|
71
|
+
parsed = int(value)
|
|
72
|
+
except ValueError:
|
|
73
|
+
return int(default)
|
|
74
|
+
return parsed if parsed > 0 else int(default)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MatrixClass(str, Enum):
|
|
78
|
+
"""Declared numerical class of a sparse matrix."""
|
|
79
|
+
|
|
80
|
+
SPD = "spd"
|
|
81
|
+
SYMMETRIC_SEMIDEFINITE = "symmetric_semidefinite"
|
|
82
|
+
SYMMETRIC_INDEFINITE = "symmetric_indefinite"
|
|
83
|
+
GENERAL = "general"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class FactorizationHandle:
|
|
88
|
+
"""Reusable sparse factorization and solve diagnostics."""
|
|
89
|
+
|
|
90
|
+
matrix_shape: tuple[int, int]
|
|
91
|
+
matrix_class: MatrixClass
|
|
92
|
+
backend_name: str
|
|
93
|
+
ordering: str
|
|
94
|
+
signature: Optional[str]
|
|
95
|
+
factorization_time: float
|
|
96
|
+
factorization_count: int = 1
|
|
97
|
+
solve_count: int = 0
|
|
98
|
+
solve_time: float = 0.0
|
|
99
|
+
status: str = "ok"
|
|
100
|
+
failure_reason: Optional[str] = None
|
|
101
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
102
|
+
_solver: Any = field(default=None, repr=False)
|
|
103
|
+
|
|
104
|
+
def solve(self, rhs: np.ndarray) -> np.ndarray:
|
|
105
|
+
return solve(self, rhs)
|
|
106
|
+
|
|
107
|
+
def solve_many(self, rhs_matrix: np.ndarray) -> np.ndarray:
|
|
108
|
+
return solve_many(self, rhs_matrix)
|
|
109
|
+
|
|
110
|
+
def diagnostics(self) -> Dict[str, Any]:
|
|
111
|
+
return {
|
|
112
|
+
"backend": self.backend_name,
|
|
113
|
+
"matrix_class": self.matrix_class.value,
|
|
114
|
+
"ordering": self.ordering,
|
|
115
|
+
"signature": self.signature,
|
|
116
|
+
"shape": list(self.matrix_shape),
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"failure_reason": self.failure_reason,
|
|
119
|
+
"factorization_time": self.factorization_time,
|
|
120
|
+
"factorization_count": self.factorization_count,
|
|
121
|
+
"solve_count": self.solve_count,
|
|
122
|
+
"solve_time": self.solve_time,
|
|
123
|
+
**self.metadata,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SparseSolverBackend:
|
|
128
|
+
"""SciPy/SuperLU sparse backend with a stable FE-facing interface."""
|
|
129
|
+
|
|
130
|
+
name = "scipy_superlu"
|
|
131
|
+
|
|
132
|
+
def factorize(
|
|
133
|
+
self,
|
|
134
|
+
matrix: sparse.spmatrix,
|
|
135
|
+
matrix_class: MatrixClass,
|
|
136
|
+
*,
|
|
137
|
+
signature: Optional[str] = None,
|
|
138
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
139
|
+
) -> FactorizationHandle:
|
|
140
|
+
options = dict(options or {})
|
|
141
|
+
ordering = str(options.get("ordering", "COLAMD"))
|
|
142
|
+
start = time.time()
|
|
143
|
+
try:
|
|
144
|
+
csc = sparse.csc_matrix(matrix)
|
|
145
|
+
solver = splu(csc, permc_spec=ordering)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
return FactorizationHandle(
|
|
148
|
+
matrix_shape=tuple(int(v) for v in matrix.shape),
|
|
149
|
+
matrix_class=matrix_class,
|
|
150
|
+
backend_name=self.name,
|
|
151
|
+
ordering=ordering,
|
|
152
|
+
signature=signature,
|
|
153
|
+
factorization_time=time.time() - start,
|
|
154
|
+
status="failed",
|
|
155
|
+
failure_reason=str(exc),
|
|
156
|
+
)
|
|
157
|
+
return FactorizationHandle(
|
|
158
|
+
matrix_shape=tuple(int(v) for v in matrix.shape),
|
|
159
|
+
matrix_class=matrix_class,
|
|
160
|
+
backend_name=self.name,
|
|
161
|
+
ordering=ordering,
|
|
162
|
+
signature=signature,
|
|
163
|
+
factorization_time=time.time() - start,
|
|
164
|
+
_solver=solver,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_MKL_RT_ENV_READY = False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ensure_mkl_rt_env() -> None:
|
|
172
|
+
"""Locate mkl_rt once before importing PyPardiso.
|
|
173
|
+
|
|
174
|
+
Windows MKL wheels normally place the runtime directly in
|
|
175
|
+
``<python>/Library/bin``. Probe a small set of standard locations
|
|
176
|
+
non-recursively; pypardiso's recursive fallback can otherwise dominate
|
|
177
|
+
first-use time.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
global _MKL_RT_ENV_READY
|
|
181
|
+
if _MKL_RT_ENV_READY or not _HAS_PYPARDISO:
|
|
182
|
+
return
|
|
183
|
+
if os.environ.get("PYPARDISO_MKL_RT"):
|
|
184
|
+
_MKL_RT_ENV_READY = True
|
|
185
|
+
return
|
|
186
|
+
from ctypes.util import find_library
|
|
187
|
+
|
|
188
|
+
path = find_library("mkl_rt") or find_library("mkl_rt.1")
|
|
189
|
+
if path is None:
|
|
190
|
+
prefixes = [os.environ.get("CONDA_PREFIX"), sys.prefix, site.USER_BASE]
|
|
191
|
+
directories: List[Path] = []
|
|
192
|
+
for prefix in prefixes:
|
|
193
|
+
if not prefix:
|
|
194
|
+
continue
|
|
195
|
+
base = Path(prefix)
|
|
196
|
+
directories.extend((base / "Library" / "bin", base / "DLLs", base / "lib"))
|
|
197
|
+
candidates: List[Path] = []
|
|
198
|
+
for directory in directories:
|
|
199
|
+
try:
|
|
200
|
+
candidates.extend(directory.glob("mkl_rt*"))
|
|
201
|
+
except OSError:
|
|
202
|
+
continue
|
|
203
|
+
unique_candidates = {
|
|
204
|
+
os.path.normcase(os.path.abspath(str(candidate)))
|
|
205
|
+
for candidate in candidates
|
|
206
|
+
}
|
|
207
|
+
for candidate_text in sorted(unique_candidates, key=len):
|
|
208
|
+
try:
|
|
209
|
+
ctypes.CDLL(candidate_text)
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
path = candidate_text
|
|
213
|
+
break
|
|
214
|
+
if path:
|
|
215
|
+
os.environ["PYPARDISO_MKL_RT"] = path
|
|
216
|
+
_MKL_RT_ENV_READY = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _pardiso_mtype_candidates(matrix_class: MatrixClass) -> Tuple[int, ...]:
|
|
220
|
+
"""PARDISO mtypes to try for a declared matrix class, best first.
|
|
221
|
+
|
|
222
|
+
Symmetric classes factorize the upper triangle (mtype 2 Cholesky for SPD,
|
|
223
|
+
mtype -2 Bunch-Kaufman otherwise) with the general real path (mtype 11) as
|
|
224
|
+
the final fallback.
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
if matrix_class == MatrixClass.SPD:
|
|
228
|
+
return (2, -2, 11)
|
|
229
|
+
if matrix_class in (MatrixClass.SYMMETRIC_SEMIDEFINITE, MatrixClass.SYMMETRIC_INDEFINITE):
|
|
230
|
+
return (-2, 11)
|
|
231
|
+
return (11,)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _pardiso_prepared_matrix(csr: sparse.csr_matrix, mtype: int) -> sparse.csr_matrix:
|
|
235
|
+
"""Return the CSR matrix PARDISO should factorize for the given mtype.
|
|
236
|
+
|
|
237
|
+
Symmetric mtypes take the upper triangle only; MKL additionally requires
|
|
238
|
+
every diagonal entry to be structurally present, so missing diagonals are
|
|
239
|
+
inserted as explicit zeros.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
if mtype == 11:
|
|
243
|
+
return csr
|
|
244
|
+
upper = sparse.triu(csr, format="csr")
|
|
245
|
+
diagonal = upper.diagonal()
|
|
246
|
+
if np.any(diagonal == 0.0):
|
|
247
|
+
with warnings.catch_warnings():
|
|
248
|
+
warnings.simplefilter("ignore", SparseEfficiencyWarning)
|
|
249
|
+
upper.setdiag(diagonal)
|
|
250
|
+
upper = sparse.csr_matrix(upper)
|
|
251
|
+
upper.sort_indices()
|
|
252
|
+
return upper
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _release_mkl_solver(solver: Any) -> None:
|
|
256
|
+
"""Release MKL's internal factorization memory (PARDISO phase -1)."""
|
|
257
|
+
try:
|
|
258
|
+
solver.free_memory(everything=True)
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _pardiso_full_factorize(solver: Any, prepared: sparse.csr_matrix) -> None:
|
|
264
|
+
solver._check_A(prepared)
|
|
265
|
+
solver.set_phase(12)
|
|
266
|
+
solver._call_pardiso(prepared, np.zeros((prepared.shape[0], 1)))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _PardisoPatternSlot:
|
|
270
|
+
"""One retained PARDISO instance per sparsity pattern for analysis reuse."""
|
|
271
|
+
|
|
272
|
+
__slots__ = ("solver", "mtype", "shape", "indptr", "indices", "generation")
|
|
273
|
+
|
|
274
|
+
def __init__(self, solver: Any, mtype: int, prepared: sparse.csr_matrix):
|
|
275
|
+
self.solver = solver
|
|
276
|
+
self.mtype = int(mtype)
|
|
277
|
+
self.shape = tuple(int(v) for v in prepared.shape)
|
|
278
|
+
self.indptr = prepared.indptr.copy()
|
|
279
|
+
self.indices = prepared.indices.copy()
|
|
280
|
+
self.generation = 0
|
|
281
|
+
|
|
282
|
+
def matches(self, prepared: sparse.csr_matrix, mtype: int) -> bool:
|
|
283
|
+
return (
|
|
284
|
+
self.solver is not None
|
|
285
|
+
and self.mtype == int(mtype)
|
|
286
|
+
and self.shape == tuple(int(v) for v in prepared.shape)
|
|
287
|
+
and self.indptr.size == prepared.indptr.size
|
|
288
|
+
and self.indices.size == prepared.indices.size
|
|
289
|
+
and np.array_equal(self.indptr, prepared.indptr)
|
|
290
|
+
and np.array_equal(self.indices, prepared.indices)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def release(self) -> None:
|
|
294
|
+
self.generation += 1
|
|
295
|
+
solver, self.solver = self.solver, None
|
|
296
|
+
if solver is not None:
|
|
297
|
+
_release_mkl_solver(solver)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class _PardisoFactorization:
|
|
301
|
+
"""Solve interface bound to a pattern slot.
|
|
302
|
+
|
|
303
|
+
The slot's PARDISO instance holds the factorization of the *most recent*
|
|
304
|
+
matrix with that sparsity pattern. A handle created earlier therefore
|
|
305
|
+
checks a generation token before solving; if the slot has moved on (or was
|
|
306
|
+
evicted), the handle transparently refactorizes its own matrix into a
|
|
307
|
+
private solver whose MKL memory is released by a finalizer when the handle
|
|
308
|
+
is garbage collected.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
def __init__(self, slot: _PardisoPatternSlot, prepared: sparse.csr_matrix, mtype: int):
|
|
312
|
+
self._slot: Optional[_PardisoPatternSlot] = slot
|
|
313
|
+
self._generation = slot.generation
|
|
314
|
+
self._matrix = prepared
|
|
315
|
+
self._mtype = int(mtype)
|
|
316
|
+
self._private_solver: Any = None
|
|
317
|
+
self.stale_rebuild_count = 0
|
|
318
|
+
|
|
319
|
+
def _active_solver(self) -> Any:
|
|
320
|
+
if self._private_solver is not None:
|
|
321
|
+
return self._private_solver
|
|
322
|
+
slot = self._slot
|
|
323
|
+
if slot is not None and slot.solver is not None and slot.generation == self._generation:
|
|
324
|
+
return slot.solver
|
|
325
|
+
solver = _new_pypardiso_solver(mtype=self._mtype)
|
|
326
|
+
_pardiso_full_factorize(solver, self._matrix)
|
|
327
|
+
self._private_solver = solver
|
|
328
|
+
weakref.finalize(self, _release_mkl_solver, solver)
|
|
329
|
+
self._slot = None
|
|
330
|
+
self.stale_rebuild_count += 1
|
|
331
|
+
return solver
|
|
332
|
+
|
|
333
|
+
def solve(self, rhs: np.ndarray) -> np.ndarray:
|
|
334
|
+
solver = self._active_solver()
|
|
335
|
+
b = solver._check_b(self._matrix, np.asarray(rhs, dtype=np.float64))
|
|
336
|
+
solver.set_phase(33)
|
|
337
|
+
return solver._call_pardiso(self._matrix, b)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class PyPardisoSolverBackend:
|
|
341
|
+
"""Intel MKL PARDISO backend using pypardiso.
|
|
342
|
+
|
|
343
|
+
Optimizations over naive pypardiso usage:
|
|
344
|
+
|
|
345
|
+
- mkl_rt is resolved once per process (``PYPARDISO_MKL_RT``) instead of
|
|
346
|
+
re-searching the filesystem on every ``PyPardisoSolver`` construction;
|
|
347
|
+
- symmetric matrix classes factorize the upper triangle with symmetric
|
|
348
|
+
mtypes (2 / -2), falling back to the general path on numerical failure;
|
|
349
|
+
- refactorizations with an unchanged sparsity pattern reuse the symbolic
|
|
350
|
+
analysis (PARDISO phase 22) through a small LRU of pattern slots;
|
|
351
|
+
- MKL internal memory is bounded and released: evicted slots and privately
|
|
352
|
+
rebuilt factorizations free their memory (phase -1) via finalizers.
|
|
353
|
+
|
|
354
|
+
Not thread-safe; matches the existing single-threaded solver usage.
|
|
355
|
+
"""
|
|
356
|
+
|
|
357
|
+
name = "pypardiso"
|
|
358
|
+
|
|
359
|
+
def __init__(self, *, max_pattern_slots: int = 4):
|
|
360
|
+
self.max_pattern_slots = _env_int("FE_SOLVER_PYPARDISO_MAX_PATTERN_SLOTS", int(max_pattern_slots))
|
|
361
|
+
self._slots: List[_PardisoPatternSlot] = []
|
|
362
|
+
|
|
363
|
+
def release_pattern_slots(self) -> None:
|
|
364
|
+
"""Release all retained MKL factorization memory."""
|
|
365
|
+
while self._slots:
|
|
366
|
+
self._slots.pop().release()
|
|
367
|
+
|
|
368
|
+
@property
|
|
369
|
+
def initialized(self) -> bool:
|
|
370
|
+
"""Whether this backend has completed a retained factorization."""
|
|
371
|
+
|
|
372
|
+
return bool(self._slots)
|
|
373
|
+
|
|
374
|
+
def _factorize_prepared(self, prepared: sparse.csr_matrix, mtype: int) -> Tuple[_PardisoFactorization, bool]:
|
|
375
|
+
for slot in self._slots:
|
|
376
|
+
if slot.matches(prepared, mtype):
|
|
377
|
+
slot.generation += 1
|
|
378
|
+
try:
|
|
379
|
+
slot.solver.set_phase(22)
|
|
380
|
+
slot.solver._call_pardiso(prepared, np.zeros((prepared.shape[0], 1)))
|
|
381
|
+
except Exception:
|
|
382
|
+
_pardiso_full_factorize(slot.solver, prepared)
|
|
383
|
+
self._slots.remove(slot)
|
|
384
|
+
self._slots.insert(0, slot)
|
|
385
|
+
return _PardisoFactorization(slot, prepared, mtype), True
|
|
386
|
+
solver = _new_pypardiso_solver(mtype=int(mtype))
|
|
387
|
+
_pardiso_full_factorize(solver, prepared)
|
|
388
|
+
slot = _PardisoPatternSlot(solver, mtype, prepared)
|
|
389
|
+
self._slots.insert(0, slot)
|
|
390
|
+
while len(self._slots) > max(int(self.max_pattern_slots), 1):
|
|
391
|
+
self._slots.pop().release()
|
|
392
|
+
return _PardisoFactorization(slot, prepared, mtype), False
|
|
393
|
+
|
|
394
|
+
def factorize(
|
|
395
|
+
self,
|
|
396
|
+
matrix: sparse.spmatrix,
|
|
397
|
+
matrix_class: MatrixClass,
|
|
398
|
+
*,
|
|
399
|
+
signature: Optional[str] = None,
|
|
400
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
401
|
+
) -> FactorizationHandle:
|
|
402
|
+
start = time.time()
|
|
403
|
+
_ensure_mkl_rt_env()
|
|
404
|
+
failure_reasons: List[str] = []
|
|
405
|
+
try:
|
|
406
|
+
csr = sparse.csr_matrix(matrix)
|
|
407
|
+
if csr is matrix:
|
|
408
|
+
csr = csr.copy()
|
|
409
|
+
csr.sort_indices()
|
|
410
|
+
except Exception as exc:
|
|
411
|
+
failure_reasons.append(str(exc))
|
|
412
|
+
csr = None
|
|
413
|
+
wrapper: Optional[_PardisoFactorization] = None
|
|
414
|
+
used_mtype: Optional[int] = None
|
|
415
|
+
symbolic_reused = False
|
|
416
|
+
if csr is not None:
|
|
417
|
+
for mtype in _pardiso_mtype_candidates(matrix_class):
|
|
418
|
+
try:
|
|
419
|
+
prepared = _pardiso_prepared_matrix(csr, mtype)
|
|
420
|
+
wrapper, symbolic_reused = self._factorize_prepared(prepared, mtype)
|
|
421
|
+
used_mtype = int(mtype)
|
|
422
|
+
break
|
|
423
|
+
except Exception as exc:
|
|
424
|
+
failure_reasons.append(f"mtype={mtype}: {exc}")
|
|
425
|
+
if wrapper is None:
|
|
426
|
+
return FactorizationHandle(
|
|
427
|
+
matrix_shape=tuple(int(v) for v in matrix.shape),
|
|
428
|
+
matrix_class=matrix_class,
|
|
429
|
+
backend_name=self.name,
|
|
430
|
+
ordering="MKL PARDISO",
|
|
431
|
+
signature=signature,
|
|
432
|
+
factorization_time=time.time() - start,
|
|
433
|
+
status="failed",
|
|
434
|
+
failure_reason="; ".join(failure_reasons) or "unknown pypardiso failure",
|
|
435
|
+
)
|
|
436
|
+
handle = FactorizationHandle(
|
|
437
|
+
matrix_shape=tuple(int(v) for v in matrix.shape),
|
|
438
|
+
matrix_class=matrix_class,
|
|
439
|
+
backend_name=self.name,
|
|
440
|
+
ordering="MKL PARDISO",
|
|
441
|
+
signature=signature,
|
|
442
|
+
factorization_time=time.time() - start,
|
|
443
|
+
_solver=wrapper,
|
|
444
|
+
)
|
|
445
|
+
handle.metadata["pardiso_mtype"] = used_mtype
|
|
446
|
+
handle.metadata["pardiso_symbolic_reused"] = bool(symbolic_reused)
|
|
447
|
+
handle.metadata["pardiso_pattern_slots"] = len(self._slots)
|
|
448
|
+
if failure_reasons:
|
|
449
|
+
handle.metadata["pardiso_fallback_attempts"] = list(failure_reasons)
|
|
450
|
+
return handle
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
class AutoSparseSolverBackend:
|
|
454
|
+
"""Size-aware backend selector with SciPy fallback.
|
|
455
|
+
|
|
456
|
+
PyPardiso has substantial setup overhead on tiny systems. The auto backend
|
|
457
|
+
keeps SuperLU as the fast small-matrix path and uses PyPardiso only once the
|
|
458
|
+
matrix is large enough to amortize that overhead. Callers may force a
|
|
459
|
+
backend with ``options={"backend": "pypardiso"}`` or
|
|
460
|
+
``options={"backend": "scipy_superlu"}``.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
name = "auto"
|
|
464
|
+
|
|
465
|
+
def __init__(
|
|
466
|
+
self,
|
|
467
|
+
*,
|
|
468
|
+
scipy_backend: Optional[SparseSolverBackend] = None,
|
|
469
|
+
pardiso_backend: Optional[PyPardisoSolverBackend] = None,
|
|
470
|
+
pypardiso_min_dimension: int = 10_000,
|
|
471
|
+
pypardiso_min_nnz: int = 250_000,
|
|
472
|
+
pypardiso_warm_min_dimension: int = 1_000,
|
|
473
|
+
pypardiso_warm_min_nnz: int = 25_000,
|
|
474
|
+
):
|
|
475
|
+
self.scipy_backend = scipy_backend or SparseSolverBackend()
|
|
476
|
+
self.pardiso_backend = (pardiso_backend or PyPardisoSolverBackend()) if _HAS_PYPARDISO else None
|
|
477
|
+
self.pypardiso_min_dimension = _env_int("FE_SOLVER_PYPARDISO_MIN_DIMENSION", int(pypardiso_min_dimension))
|
|
478
|
+
self.pypardiso_min_nnz = _env_int("FE_SOLVER_PYPARDISO_MIN_NNZ", int(pypardiso_min_nnz))
|
|
479
|
+
self.pypardiso_warm_min_dimension = _env_int(
|
|
480
|
+
"FE_SOLVER_PYPARDISO_WARM_MIN_DIMENSION",
|
|
481
|
+
int(pypardiso_warm_min_dimension),
|
|
482
|
+
)
|
|
483
|
+
self.pypardiso_warm_min_nnz = _env_int(
|
|
484
|
+
"FE_SOLVER_PYPARDISO_WARM_MIN_NNZ",
|
|
485
|
+
int(pypardiso_warm_min_nnz),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
def release_pattern_slots(self) -> None:
|
|
489
|
+
"""Release MKL factorization memory retained for pattern reuse."""
|
|
490
|
+
if self.pardiso_backend is not None:
|
|
491
|
+
self.pardiso_backend.release_pattern_slots()
|
|
492
|
+
|
|
493
|
+
def _selection_thresholds(self, options: Mapping[str, Any]) -> Tuple[int, int, bool]:
|
|
494
|
+
is_warm = bool(self.pardiso_backend and self.pardiso_backend.initialized)
|
|
495
|
+
default_dimension = self.pypardiso_warm_min_dimension if is_warm else self.pypardiso_min_dimension
|
|
496
|
+
default_nnz = self.pypardiso_warm_min_nnz if is_warm else self.pypardiso_min_nnz
|
|
497
|
+
min_dimension = int(options.get("pypardiso_min_dimension", default_dimension))
|
|
498
|
+
min_nnz = int(options.get("pypardiso_min_nnz", default_nnz))
|
|
499
|
+
return min_dimension, min_nnz, is_warm
|
|
500
|
+
|
|
501
|
+
def _use_pypardiso(
|
|
502
|
+
self,
|
|
503
|
+
matrix: sparse.spmatrix,
|
|
504
|
+
options: Mapping[str, Any],
|
|
505
|
+
min_dimension: int,
|
|
506
|
+
min_nnz: int,
|
|
507
|
+
) -> bool:
|
|
508
|
+
requested = str(options.get("backend", options.get("solver", "auto"))).lower()
|
|
509
|
+
if requested in {"scipy", "scipy_superlu", "superlu"}:
|
|
510
|
+
return False
|
|
511
|
+
if requested in {"pypardiso", "pardiso", "mkl_pardiso"}:
|
|
512
|
+
return self.pardiso_backend is not None
|
|
513
|
+
if self.pardiso_backend is None:
|
|
514
|
+
return False
|
|
515
|
+
return max(int(matrix.shape[0]), int(matrix.shape[1])) >= min_dimension and int(matrix.nnz) >= min_nnz
|
|
516
|
+
|
|
517
|
+
def factorize(
|
|
518
|
+
self,
|
|
519
|
+
matrix: sparse.spmatrix,
|
|
520
|
+
matrix_class: MatrixClass,
|
|
521
|
+
*,
|
|
522
|
+
signature: Optional[str] = None,
|
|
523
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
524
|
+
) -> FactorizationHandle:
|
|
525
|
+
options_dict = dict(options or {})
|
|
526
|
+
active_dimension, active_nnz, was_initialized = self._selection_thresholds(options_dict)
|
|
527
|
+
selection_metadata = {
|
|
528
|
+
"pypardiso_active_min_dimension": active_dimension,
|
|
529
|
+
"pypardiso_active_min_nnz": active_nnz,
|
|
530
|
+
"pypardiso_initialized_before_selection": was_initialized,
|
|
531
|
+
}
|
|
532
|
+
if not self._use_pypardiso(matrix, options_dict, active_dimension, active_nnz):
|
|
533
|
+
handle = self.scipy_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
|
|
534
|
+
handle.metadata.setdefault("auto_backend_policy", "scipy_small_matrix")
|
|
535
|
+
handle.metadata.setdefault("pypardiso_min_dimension", self.pypardiso_min_dimension)
|
|
536
|
+
handle.metadata.setdefault("pypardiso_min_nnz", self.pypardiso_min_nnz)
|
|
537
|
+
for key, value in selection_metadata.items():
|
|
538
|
+
handle.metadata.setdefault(key, value)
|
|
539
|
+
handle.metadata.setdefault(
|
|
540
|
+
"pypardiso_initialized",
|
|
541
|
+
bool(self.pardiso_backend and self.pardiso_backend.initialized),
|
|
542
|
+
)
|
|
543
|
+
return handle
|
|
544
|
+
|
|
545
|
+
assert self.pardiso_backend is not None
|
|
546
|
+
handle = self.pardiso_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
|
|
547
|
+
handle.metadata.setdefault("auto_backend_policy", "pypardiso_large_matrix")
|
|
548
|
+
handle.metadata.setdefault("pypardiso_min_dimension", self.pypardiso_min_dimension)
|
|
549
|
+
handle.metadata.setdefault("pypardiso_min_nnz", self.pypardiso_min_nnz)
|
|
550
|
+
for key, value in selection_metadata.items():
|
|
551
|
+
handle.metadata.setdefault(key, value)
|
|
552
|
+
handle.metadata.setdefault("pypardiso_initialized", bool(self.pardiso_backend.initialized))
|
|
553
|
+
if handle.status == "ok":
|
|
554
|
+
return handle
|
|
555
|
+
|
|
556
|
+
fallback = self.scipy_backend.factorize(matrix, matrix_class, signature=signature, options=options_dict)
|
|
557
|
+
fallback.metadata["auto_backend_policy"] = "scipy_after_pypardiso_failure"
|
|
558
|
+
fallback.metadata["pypardiso_min_dimension"] = self.pypardiso_min_dimension
|
|
559
|
+
fallback.metadata["pypardiso_min_nnz"] = self.pypardiso_min_nnz
|
|
560
|
+
fallback.metadata.update(selection_metadata)
|
|
561
|
+
fallback.metadata["fallback_from_backend"] = handle.backend_name
|
|
562
|
+
fallback.metadata["fallback_failure_reason"] = handle.failure_reason
|
|
563
|
+
return fallback
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
DEFAULT_BACKEND = AutoSparseSolverBackend() if _HAS_PYPARDISO else SparseSolverBackend()
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _options_signature(options: Optional[Mapping[str, Any]]) -> str:
|
|
570
|
+
return json.dumps(dict(options or {}), sort_keys=True, default=str, separators=(",", ":"))
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def sparse_matrix_signature(matrix: sparse.spmatrix) -> str:
|
|
574
|
+
"""Content signature for a sparse matrix used in local factorization caches."""
|
|
575
|
+
|
|
576
|
+
csr = sparse.csr_matrix(matrix)
|
|
577
|
+
digest = hashlib.sha256()
|
|
578
|
+
digest.update(str(tuple(int(v) for v in csr.shape)).encode("ascii"))
|
|
579
|
+
digest.update(str(int(csr.nnz)).encode("ascii"))
|
|
580
|
+
digest.update(np.asarray(csr.indptr, dtype=np.int64).tobytes())
|
|
581
|
+
digest.update(np.asarray(csr.indices, dtype=np.int64).tobytes())
|
|
582
|
+
digest.update(np.asarray(csr.data, dtype=np.float64).tobytes())
|
|
583
|
+
return digest.hexdigest()
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@dataclass
|
|
587
|
+
class FactorizationCache:
|
|
588
|
+
"""Explicit local cache for sparse factorizations.
|
|
589
|
+
|
|
590
|
+
The cache is intentionally not global. Analyses that can safely reuse a
|
|
591
|
+
matrix factorization own the cache and therefore own its lifetime.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
name: str = "factorization_cache"
|
|
595
|
+
max_entries: int = 8
|
|
596
|
+
backend: Optional[SparseSolverBackend] = None
|
|
597
|
+
_handles: Dict[Tuple[str, str, str, str], FactorizationHandle] = field(default_factory=dict, init=False, repr=False)
|
|
598
|
+
hits: int = 0
|
|
599
|
+
misses: int = 0
|
|
600
|
+
factorization_failures: int = 0
|
|
601
|
+
|
|
602
|
+
def key(
|
|
603
|
+
self,
|
|
604
|
+
matrix: sparse.spmatrix,
|
|
605
|
+
matrix_class: MatrixClass | str,
|
|
606
|
+
*,
|
|
607
|
+
signature: Optional[str] = None,
|
|
608
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
609
|
+
) -> Tuple[str, str, str, str]:
|
|
610
|
+
matrix_key = str(signature) if signature is not None else sparse_matrix_signature(matrix)
|
|
611
|
+
return (
|
|
612
|
+
matrix_key,
|
|
613
|
+
_coerce_matrix_class(matrix_class).value,
|
|
614
|
+
_options_signature(options),
|
|
615
|
+
tuple(int(v) for v in matrix.shape).__repr__(),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
def factorize(
|
|
619
|
+
self,
|
|
620
|
+
matrix: sparse.spmatrix,
|
|
621
|
+
matrix_class: MatrixClass | str,
|
|
622
|
+
*,
|
|
623
|
+
signature: Optional[str] = None,
|
|
624
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
625
|
+
) -> FactorizationHandle:
|
|
626
|
+
cache_key = self.key(matrix, matrix_class, signature=signature, options=options)
|
|
627
|
+
if cache_key in self._handles:
|
|
628
|
+
self.hits += 1
|
|
629
|
+
handle = self._handles[cache_key]
|
|
630
|
+
handle.metadata["cache_name"] = self.name
|
|
631
|
+
handle.metadata["cache_hit"] = True
|
|
632
|
+
return handle
|
|
633
|
+
self.misses += 1
|
|
634
|
+
handle = factorize(
|
|
635
|
+
matrix,
|
|
636
|
+
matrix_class,
|
|
637
|
+
signature=signature or cache_key[0],
|
|
638
|
+
options=options,
|
|
639
|
+
backend=self.backend,
|
|
640
|
+
)
|
|
641
|
+
handle.metadata["cache_name"] = self.name
|
|
642
|
+
handle.metadata["cache_hit"] = False
|
|
643
|
+
if handle.status == "ok":
|
|
644
|
+
if self.max_entries > 0 and len(self._handles) >= self.max_entries:
|
|
645
|
+
oldest = next(iter(self._handles))
|
|
646
|
+
self._handles.pop(oldest, None)
|
|
647
|
+
if self.max_entries != 0:
|
|
648
|
+
self._handles[cache_key] = handle
|
|
649
|
+
else:
|
|
650
|
+
self.factorization_failures += 1
|
|
651
|
+
return handle
|
|
652
|
+
|
|
653
|
+
def linear_operator(
|
|
654
|
+
self,
|
|
655
|
+
matrix: sparse.spmatrix,
|
|
656
|
+
matrix_class: MatrixClass | str,
|
|
657
|
+
*,
|
|
658
|
+
signature: Optional[str] = None,
|
|
659
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
660
|
+
) -> tuple[LinearOperator, FactorizationHandle]:
|
|
661
|
+
"""Return a LinearOperator that applies the cached inverse."""
|
|
662
|
+
|
|
663
|
+
handle = self.factorize(matrix, matrix_class, signature=signature, options=options)
|
|
664
|
+
if handle.status != "ok":
|
|
665
|
+
raise RuntimeError(f"Cannot create inverse operator from failed factorization: {handle.failure_reason}")
|
|
666
|
+
|
|
667
|
+
def matvec(rhs: np.ndarray) -> np.ndarray:
|
|
668
|
+
return handle.solve(rhs)
|
|
669
|
+
|
|
670
|
+
operator = LinearOperator(matrix.shape, matvec=matvec, dtype=float)
|
|
671
|
+
return operator, handle
|
|
672
|
+
|
|
673
|
+
def diagnostics(self) -> Dict[str, Any]:
|
|
674
|
+
return {
|
|
675
|
+
"name": self.name,
|
|
676
|
+
"max_entries": int(self.max_entries),
|
|
677
|
+
"entries": int(len(self._handles)),
|
|
678
|
+
"hits": int(self.hits),
|
|
679
|
+
"misses": int(self.misses),
|
|
680
|
+
"factorization_failures": int(self.factorization_failures),
|
|
681
|
+
"backend": (self.backend or DEFAULT_BACKEND).name,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
def clear(self) -> None:
|
|
685
|
+
self._handles.clear()
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _coerce_matrix_class(matrix_class: MatrixClass | str) -> MatrixClass:
|
|
689
|
+
if isinstance(matrix_class, MatrixClass):
|
|
690
|
+
return matrix_class
|
|
691
|
+
return MatrixClass(str(matrix_class))
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def factorize(
|
|
695
|
+
matrix: sparse.spmatrix,
|
|
696
|
+
matrix_class: MatrixClass | str,
|
|
697
|
+
*,
|
|
698
|
+
signature: Optional[str] = None,
|
|
699
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
700
|
+
backend: Optional[SparseSolverBackend] = None,
|
|
701
|
+
) -> FactorizationHandle:
|
|
702
|
+
"""Factorize a sparse matrix and return a reusable handle."""
|
|
703
|
+
backend = backend or DEFAULT_BACKEND
|
|
704
|
+
return backend.factorize(matrix, _coerce_matrix_class(matrix_class), signature=signature, options=options)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def factorize_cached(
|
|
708
|
+
matrix: sparse.spmatrix,
|
|
709
|
+
matrix_class: MatrixClass | str,
|
|
710
|
+
*,
|
|
711
|
+
cache: Optional[FactorizationCache] = None,
|
|
712
|
+
signature: Optional[str] = None,
|
|
713
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
714
|
+
backend: Optional[SparseSolverBackend] = None,
|
|
715
|
+
) -> FactorizationHandle:
|
|
716
|
+
"""Factorize through a local cache when supplied."""
|
|
717
|
+
|
|
718
|
+
if cache is None:
|
|
719
|
+
return factorize(matrix, matrix_class, signature=signature, options=options, backend=backend)
|
|
720
|
+
if backend is not None and cache.backend is None:
|
|
721
|
+
cache.backend = backend
|
|
722
|
+
return cache.factorize(matrix, matrix_class, signature=signature, options=options)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def cached_inverse_operator(
|
|
726
|
+
matrix: sparse.spmatrix,
|
|
727
|
+
matrix_class: MatrixClass | str,
|
|
728
|
+
*,
|
|
729
|
+
cache: Optional[FactorizationCache] = None,
|
|
730
|
+
signature: Optional[str] = None,
|
|
731
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
732
|
+
) -> tuple[LinearOperator, FactorizationHandle]:
|
|
733
|
+
"""Build a sparse inverse operator, using a local cache if supplied."""
|
|
734
|
+
|
|
735
|
+
local_cache = cache or FactorizationCache(name="single_inverse_operator", max_entries=1)
|
|
736
|
+
return local_cache.linear_operator(matrix, matrix_class, signature=signature, options=options)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def solve(handle: FactorizationHandle, rhs: np.ndarray) -> np.ndarray:
|
|
740
|
+
"""Solve one right-hand side using an existing factorization."""
|
|
741
|
+
if handle.status != "ok" or handle._solver is None:
|
|
742
|
+
raise RuntimeError(f"Cannot solve with failed factorization: {handle.failure_reason}")
|
|
743
|
+
rhs = np.asarray(rhs, dtype=float)
|
|
744
|
+
start = time.time()
|
|
745
|
+
result = np.asarray(handle._solver.solve(rhs), dtype=float)
|
|
746
|
+
handle.solve_time += time.time() - start
|
|
747
|
+
handle.solve_count += 1 if rhs.ndim == 1 else int(rhs.shape[1])
|
|
748
|
+
if np.any(~np.isfinite(result)):
|
|
749
|
+
raise RuntimeError("Sparse solve produced NaN/Inf values")
|
|
750
|
+
return result
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def solve_many(handle: FactorizationHandle, rhs_matrix: np.ndarray) -> np.ndarray:
|
|
754
|
+
"""Solve one or more right-hand sides with one numerical factorization."""
|
|
755
|
+
rhs = np.asarray(rhs_matrix, dtype=float)
|
|
756
|
+
if rhs.ndim == 1:
|
|
757
|
+
return solve(handle, rhs)
|
|
758
|
+
if rhs.ndim != 2:
|
|
759
|
+
raise ValueError("rhs_matrix must be one- or two-dimensional")
|
|
760
|
+
return solve(handle, rhs)
|