Skip to content

Commit 6492257

Browse files
kwlee2025cppclaude
andcommitted
Add benchmarks for scipy vs slycot matrix-equation fallbacks
Add benchmarks/scipy_fallback_bench.py (asv) comparing the pure-scipy fallbacks with slycot for generalized Lyapunov (continuous and discrete) and discrete Sylvester, plus an accuracy-vs-cond(E) track for the generalized Lyapunov solution. slycot parameterizations are skipped when slycot is not installed. Requested in the PR #1234 review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 55f5826 commit 6492257

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

benchmarks/scipy_fallback_bench.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# scipy_fallback_bench.py - benchmarks for the SLICOT-free (scipy) fallbacks
2+
# KL, 1 Jul 2026
3+
#
4+
# This benchmark compares the pure scipy/numpy fallbacks against the SLICOT
5+
# (slycot) implementations for the matrix-equation routines that gained a
6+
# ``method`` argument:
7+
#
8+
# * generalized continuous Lyapunov lyap(A, Q, E=E)
9+
# * generalized discrete Lyapunov dlyap(A, Q, E=E)
10+
# * discrete Sylvester (Stein) dlyap(A, Q, C)
11+
#
12+
# The ``time_*`` methods time each (routine, size, method) combination. The
13+
# ``track_*`` method records the accuracy of the generalized-Lyapunov solution
14+
# as a function of cond(E). Every problem is constructed from a known solution
15+
# ``X`` so that both speed and accuracy are measured against ground truth; the
16+
# ``setup`` methods therefore build the matrices *outside* the timed region.
17+
#
18+
# When slycot is not installed the ``method='slycot'`` parameterizations are
19+
# skipped (asv treats NotImplementedError raised in setup() as "skip"), so the
20+
# suite runs with or without slycot.
21+
#
22+
# A single deterministic seed is used per problem, so runs are reproducible and
23+
# comparable across commits. (The tables discussed in PR #1234 were medians
24+
# over several seeds; the ratios here match, as asv's repeated sampling
25+
# averages the timing.)
26+
#
27+
# Run, e.g.:
28+
#
29+
# PYTHONPATH=`pwd` asv run --python=python --bench scipy_fallback
30+
#
31+
# or, since these are plain classes, call the methods directly to reproduce the
32+
# numbers without asv.
33+
34+
import numpy as np
35+
36+
import control as ct
37+
38+
# Fixed seed: deterministic, reproducible problems across runs and commits.
39+
SEED = 20260627
40+
41+
42+
def _slycot_available():
43+
try:
44+
return ct.slycot_check()
45+
except Exception:
46+
return False
47+
48+
49+
def _spd(rng, n):
50+
"""Return a symmetric positive-definite n-by-n matrix."""
51+
P = rng.standard_normal((n, n))
52+
return P @ P.T + n * np.eye(n)
53+
54+
55+
def _make_gen_cont_lyap(rng, n):
56+
# A X E' + E X A' + Q = 0, built from a known SPD solution X (A Hurwitz).
57+
E = np.eye(n) + 0.1 * rng.standard_normal((n, n))
58+
M = rng.standard_normal((n, n))
59+
S = M - (np.linalg.norm(M, 2) + 1.0) * np.eye(n)
60+
A = E @ S
61+
X = _spd(rng, n)
62+
Q = -(A @ X @ E.T + E @ X @ A.T)
63+
Q = 0.5 * (Q + Q.T)
64+
return ct.lyap, (A, Q), dict(E=E), X
65+
66+
67+
def _make_gen_disc_lyap(rng, n):
68+
# A X A' - E X E' + Q = 0, built from a known SPD solution X (A Schur).
69+
E = np.eye(n) + 0.1 * rng.standard_normal((n, n))
70+
M = rng.standard_normal((n, n))
71+
S = M / (np.linalg.norm(M, 2) + 1.0)
72+
A = E @ S
73+
X = _spd(rng, n)
74+
Q = -(A @ X @ A.T - E @ X @ E.T)
75+
Q = 0.5 * (Q + Q.T)
76+
return ct.dlyap, (A, Q), dict(E=E), X
77+
78+
79+
def _make_disc_sylvester(rng, n):
80+
# A X Q' - X + C = 0 (discrete Sylvester / Stein), from a known X.
81+
MA = rng.standard_normal((n, n))
82+
MQ = rng.standard_normal((n, n))
83+
A = MA / (np.linalg.norm(MA, 2) + 1.0)
84+
Q = MQ / (np.linalg.norm(MQ, 2) + 1.0)
85+
X = rng.standard_normal((n, n))
86+
C = X - A @ X @ Q.T
87+
return ct.dlyap, (A, Q, C), dict(), X
88+
89+
90+
_MAKERS = {
91+
'gen_cont_lyap': _make_gen_cont_lyap,
92+
'gen_disc_lyap': _make_gen_disc_lyap,
93+
'disc_sylvester': _make_disc_sylvester,
94+
}
95+
96+
97+
class MatrixEquationTiming:
98+
"""Time the scipy fallback against slycot for the ``method=`` routines."""
99+
100+
params = (
101+
['gen_cont_lyap', 'gen_disc_lyap', 'disc_sylvester'],
102+
[10, 50, 100, 200, 400],
103+
['scipy', 'slycot'],
104+
)
105+
param_names = ['routine', 'n', 'method']
106+
timeout = 120
107+
108+
def setup(self, routine, n, method):
109+
if method == 'slycot' and not _slycot_available():
110+
raise NotImplementedError("slycot not available")
111+
rng = np.random.default_rng(SEED)
112+
self.func, self.args, self.kwargs, X = _MAKERS[routine](rng, n)
113+
# Confirm the method actually solves the problem before timing it.
114+
Xhat = self.func(*self.args, method=method, **self.kwargs)
115+
relerr = np.linalg.norm(Xhat - X, 'fro') / np.linalg.norm(X, 'fro')
116+
assert relerr < 1e-6, f"{routine} {method} n={n}: relerr={relerr:.1e}"
117+
118+
def time_solve(self, routine, n, method):
119+
self.func(*self.args, method=method, **self.kwargs)
120+
121+
122+
class GenLyapAccuracy:
123+
"""Track generalized continuous Lyapunov accuracy versus cond(E).
124+
125+
Both the scipy and slycot paths require E nonsingular and degrade together
126+
as E becomes ill-conditioned (the problem is itself about cond(E)**2
127+
conditioned); this benchmark records that, rather than timing.
128+
"""
129+
130+
params = (
131+
[1e0, 1e2, 1e4, 1e6, 1e8, 1e10, 1e12],
132+
['scipy', 'slycot'],
133+
)
134+
param_names = ['cond_E', 'method']
135+
unit = "relative error"
136+
n = 100
137+
138+
def setup(self, cond_E, method):
139+
if method == 'slycot' and not _slycot_available():
140+
raise NotImplementedError("slycot not available")
141+
n = self.n
142+
rng = np.random.default_rng(SEED)
143+
U, _ = np.linalg.qr(rng.standard_normal((n, n)))
144+
V, _ = np.linalg.qr(rng.standard_normal((n, n)))
145+
M = rng.standard_normal((n, n))
146+
S = M - (np.linalg.norm(M, 2) + 1.0) * np.eye(n)
147+
X = _spd(rng, n)
148+
# E with prescribed condition number: singular values spanning cond_E.
149+
E = (U * np.logspace(0, -np.log10(cond_E), n)) @ V.T
150+
A = E @ S
151+
Q = -(A @ X @ E.T + E @ X @ A.T)
152+
self.A, self.Q, self.E, self.X = A, 0.5 * (Q + Q.T), E, X
153+
154+
def track_relerr(self, cond_E, method):
155+
import warnings
156+
with warnings.catch_warnings():
157+
# Ill-conditioned E deliberately triggers the accuracy warning.
158+
warnings.simplefilter("ignore")
159+
Xhat = ct.lyap(self.A, self.Q, E=self.E, method=method)
160+
return float(np.linalg.norm(Xhat - self.X, 'fro')
161+
/ np.linalg.norm(self.X, 'fro'))

0 commit comments

Comments
 (0)