|
| 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