Skip to content

Commit 57c3c48

Browse files
baggepinnenclaude
andcommitted
Add freqresp! optimization benchmark
Standalone benchmark comparing strategies for freqresp! on a 48-state SISO state-space system over 150 frequencies: - baseline / serial Hessenberg ldiv2! - threaded outer loop (Threads.@Spawn, Polyester.@Batch, OhMyThreads) - @turbo / @tturbo ldiv2! (real/imag split, since @turbo lacks complex support) - modal / pole-residue reformulation (the Reactant/XLA/GPU-friendly form) Findings: the per-frequency loop dominates over the factorization; @turbo gives ~1.5x, threading ~6-7x, and the modal reformulation 13-54x (at the cost of robustness, depending on cond(V)). @tturbo and Reactant are not worth it at this problem size. See benchmark_freqresp/README.md for full results. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 856eb2b commit 57c3c48

4 files changed

Lines changed: 681 additions & 0 deletions

File tree

benchmark_freqresp/Project.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[deps]
2+
BenchmarkTools = "6e4b80f9-dd63-53aa-95a3-0cdb28fa8baf"
3+
ControlSystemsBase = "aaaaaaaa-a6ca-5380-bf3e-84a91bcd477e"
4+
LoopVectorization = "bdcacae8-1622-11e9-2a5c-532679323890"
5+
OhMyThreads = "67456a42-1dca-4109-a031-0a68de7e3ad5"
6+
Polyester = "f517fe37-dbe3-4b94-8317-1923a5111588"

benchmark_freqresp/README.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# `freqresp!` optimization benchmark
2+
3+
Standalone benchmark of strategies for `freqresp!` on the target use case:
4+
a **48-state SISO continuous state-space system over 150 frequencies**.
5+
6+
Nothing in the package source is modified — all candidate implementations live in
7+
`variants.jl`. The package's own `freqresp!` is the correctness oracle.
8+
9+
## Run
10+
11+
```bash
12+
julia --project=. -e 'using Pkg; Pkg.develop(path="../lib/ControlSystemsBase"); \
13+
Pkg.add(["BenchmarkTools","LoopVectorization","Polyester","OhMyThreads"]); \
14+
Pkg.instantiate(); Pkg.precompile()'
15+
16+
JULIA_EXCLUSIVE=1 julia -t auto --project=. benchmark.jl # threads fixed at startup
17+
```
18+
19+
## Variants
20+
21+
| ID | Strategy |
22+
|-----|----------------------------------------------------------------------|
23+
| V0 | `ControlSystemsBase.freqresp!` (oracle, full call incl. factorization) |
24+
| V1 | serial hand-rolled loop, original `ldiv2!` (loop-only) |
25+
| V2 | `Threads.@spawn` over contiguous frequency chunks |
26+
| V2b | `Polyester.@batch` over chunks |
27+
| V2c | `OhMyThreads.@tasks` with `TaskLocalValue` buffers |
28+
| V3 | serial loop, `@turbo` `ldiv2!` (real/imag split) |
29+
| V4 | `@spawn` + `@turbo` `ldiv2!` |
30+
| V4b | `Polyester.@batch` + `@turbo` `ldiv2!` |
31+
32+
All variants operate on pre-factored matrices (`A=F.H`, `C=complex.(sys.C*Q)`,
33+
`B=Q\sys.B`, `D`) so the "loop-only" timing isolates the per-frequency strategy from the
34+
one-time Hessenberg factorization. All pass the `isapprox(·, ·; rtol=1e-10)` correctness
35+
gate against the package baseline.
36+
37+
## The `@turbo` / complex caveat
38+
39+
LoopVectorization's `@turbo` does **not** support `Complex`. A bare `@turbo` on the
40+
complex `ldiv2!` loops will not compile, and a naive `reinterpret`-to-real is *silently
41+
wrong* (complex multiply has re/im cross terms). `ldiv2_turbo!` therefore carries the
42+
state as separate real/imag `Float64` arrays and writes out the complex arithmetic by
43+
hand, so the two hot `j = 1:k-2` loops (X update, u update) become valid real `@turbo`
44+
loops. Result matches the baseline to ~1e-14.
45+
46+
## Results (this machine: 24 logical cores)
47+
48+
Loop-only time, µs (lower is better):
49+
50+
| Variant | -t 4 | -t 8 | -t 24 |
51+
|--------------------|-------|-------|-------|
52+
| V1 serial | 545 | 566 | 565 |
53+
| V3 turbo | 360 | 372 | 372 |
54+
| V2 threaded | 147 | 90 | 106 |
55+
| V2b polyester | 203 | 108 | 95 |
56+
| V2c omt | 154 | 96 | 127 |
57+
| V4 threaded+turbo | **104** | **78** | 101 |
58+
| V4b polyester+turbo| 142 | 80 | **93** |
59+
60+
Full-call baseline (V0, factorization included): ~625–655 µs.
61+
62+
## Findings
63+
64+
1. **The per-frequency loop, not the factorization, dominates.** The serial loop is
65+
~565 µs while the one-time Hessenberg factorization is only ~85 µs (full call ≈ loop +
66+
factorization ≈ 650 µs). So optimizing the loop is worthwhile.
67+
68+
2. **`@turbo` is a solid, free win: ~1.5×** (565 → ~370 µs serial), more than expected for
69+
such short loops, with no threads and negligible accuracy loss. It does require the
70+
manual real/imag split.
71+
72+
3. **Threading gives ~6–7×** but scales sub-linearly: at this size **8 threads beats 24**
73+
(150 freqs / 24 threads ≈ 6 freqs each — task overhead and oversubscription dominate).
74+
The sweet spot here is ~8 threads.
75+
76+
4. **Best overall is threading + `@turbo`** (~78 µs at 8 threads, a **~7.2×** speedup over
77+
serial). `@spawn`+`@turbo` (V4) is consistently near-best across thread counts;
78+
`Polyester`+`@turbo` (V4b) edges ahead only at 24 threads.
79+
80+
5. **Scheduler choice is secondary.** `@spawn`, `Polyester`, and `OhMyThreads` are within
81+
~1.4× of each other; `@spawn` is the most robust across thread counts and adds no
82+
dependency.
83+
84+
## Reactant.jl and the modal reformulation
85+
86+
Reactant.jl traces Julia into MLIR/XLA and wants vectorized array programs. The custom
87+
Givens-based `ldiv2!` (scalar indexing, data-dependent control flow, `givensAlgorithm`)
88+
does **not** trace into efficient XLA. To use Reactant you must first rewrite the
89+
computation as batched array ops — and the natural rewrite is the **modal / pole-residue**
90+
form: eigendecompose `A = V Λ V⁻¹` once, then
91+
92+
```
93+
R(ω) = D + Σⱼ resⱼ / (iω − λⱼ), resⱼ = (C·V)ⱼ · (V⁻¹·B)ⱼ
94+
```
95+
96+
Every frequency is then a pure broadcast + reduction over an `nx × nfreq` grid — no
97+
per-frequency linear solve. Benchmarked in plain Julia (variants M1/M2/M3):
98+
99+
| Variant | µs (8 threads) |
100+
|--------------------|----------------|
101+
| M1 modal serial | 42 |
102+
| M2 modal broadcast | 59 |
103+
| M3 modal threaded | **10** |
104+
105+
So the modal form alone is **13×** (serial) to **54×** (threaded) faster than the current
106+
serial Hessenberg loop, and beats the best Hessenberg micro-optimization (threaded+`@turbo`,
107+
~73 µs) by 7×. Accuracy matched the baseline to ~1e-14 here (`cond(V)=147`).
108+
109+
**Verdict on Reactant for this problem:** not worth it. The data is tiny (48×150 ≈ 7200
110+
complex numbers) and the modal form already runs in ~10 µs on CPU — below the per-call XLA
111+
dispatch / GPU kernel-launch latency (tens of µs). Reactant would add a heavy dependency
112+
and long compile times for no gain at this size. It would only pay off at much larger scale
113+
(thousands of states, very many frequencies, or batches of systems) or on GPU — and there
114+
the same modal array program is what you would feed it.
115+
116+
**Caveat:** the modal form trades robustness for speed. It relies on the eigendecomposition,
117+
so accuracy degrades with `cond(V)` for non-normal / near-defective `A`. That conditioning
118+
risk is exactly why the package defaults to the Hessenberg solve. Modal is an excellent
119+
fast path for well-conditioned systems, not a safe drop-in default.
120+
121+
## Recommendation
122+
123+
For the package, the lowest-risk improvement is dropping in the real/imag-split `@turbo`
124+
`ldiv2!` (≈1.5× for free, no new threading semantics). If the loop is a known hotspot,
125+
add an opt-in threaded path (`@spawn` over chunks with per-task buffers) — combined with
126+
`@turbo` it reaches ~7× — but keep the default serial, since threading helps only for
127+
many frequencies and benefits from a bounded thread count.

benchmark_freqresp/benchmark.jl

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Benchmark freqresp! strategies for a 48-state SISO state-space system over 150 frequencies.
2+
#
3+
# Launch with multiple threads to exercise the threaded variants:
4+
# julia -t auto --project=. benchmark.jl
5+
#
6+
# Variants (see variants.jl):
7+
# V0 baseline ControlSystemsBase.freqresp! (correctness oracle, full call)
8+
# V1 serial hand-rolled transcription, loop-only
9+
# V2 threaded Threads.@spawn over chunks
10+
# V2b polyester Polyester.@batch over chunks
11+
# V2c omt OhMyThreads task-local buffers
12+
# V3 turbo @turbo (real/imag split) ldiv2, serial
13+
# V4 threaded+turbo @spawn + @turbo ldiv2
14+
15+
using ControlSystemsBase, BenchmarkTools, LinearAlgebra, Printf
16+
import LoopVectorization
17+
18+
include("variants.jl")
19+
20+
const NTH = Threads.nthreads()
21+
@info "Benchmark configuration" threads=NTH
22+
NTH == 1 && @warn "Running with a single thread — threaded variant timings are meaningless. Relaunch with `julia -t auto`."
23+
24+
# --- Target system & frequencies ---
25+
G = ssrand(1, 1, 48) # 48-state SISO, continuous
26+
w = exp10.(LinRange(-2, 4, 150)) # 150 log-spaced frequencies
27+
ny, nu = size(G)
28+
const T = ComplexF64
29+
make_R() = Array{T,3}(undef, ny, nu, length(w))
30+
31+
# --- Pre-factored matrices for the loop-only variants ---
32+
F = hessenberg(G.A)
33+
Q = Matrix(F.Q)
34+
A = F.H
35+
C = complex.(G.C * Q)
36+
B = Q \ G.B
37+
D = G.D
38+
39+
# Modal / pole-residue precompute (one-time eigendecomposition) — the Reactant-friendly form
40+
E = eigen(G.A)
41+
λ = ComplexF64.(E.values) # nx eigenvalues
42+
V = ComplexF64.(E.vectors)
43+
ctil = vec(complex.(G.C) * V) # C·V (nx,)
44+
btil = V \ ComplexF64.(G.B[:, 1]) # V⁻¹·B (nx,)
45+
res = ctil .* btil # residues (nx,)
46+
condV = cond(V) # conditioning of the modal transform
47+
48+
# A wrapped "full call" variant that does the factorization inside (apples-to-apples w/ V0)
49+
function freqresp_serial_full!(R, sys, w)
50+
F = hessenberg(sys.A); Q = Matrix(F.Q)
51+
freqresp_serial!(R, F.H, complex.(sys.C*Q), Q\sys.B, sys.D, w)
52+
end
53+
54+
# ----------------------------------------------------------------------------
55+
# Correctness: every variant must match the package baseline.
56+
# ----------------------------------------------------------------------------
57+
Rref = make_R(); ControlSystemsBase.freqresp!(Rref, G, w)
58+
59+
loop_variants = [
60+
("V1 serial", (R)->freqresp_serial!(R, A, C, B, D, w)),
61+
("V2 threaded", (R)->freqresp_threaded!(R, A, C, B, D, w)),
62+
("V2b polyester", (R)->freqresp_polyester!(R, A, C, B, D, w)),
63+
("V2c omt", (R)->freqresp_omt!(R, A, C, B, D, w)),
64+
("V3 turbo", (R)->freqresp_turbo!(R, A, C, B, D, w)),
65+
("V3t tturbo", (R)->freqresp_tturbo!(R, A, C, B, D, w)),
66+
("V4 threaded+turbo", (R)->freqresp_threaded_turbo!(R, A, C, B, D, w)),
67+
("V4b polyester+turbo",(R)->freqresp_polyester_turbo!(R, A, C, B, D, w)),
68+
("M1 modal", (R)->freqresp_modal!(R, λ, res, D, w)),
69+
("M2 modal broadcast", (R)->freqresp_modal_broadcast!(R, λ, res, D, w)),
70+
("M3 modal threaded", (R)->freqresp_modal_threaded!(R, λ, res, D, w)),
71+
]
72+
@info "Modal transform conditioning" condV
73+
74+
println("\n=== Correctness (rtol=1e-10 vs ControlSystemsBase.freqresp!) ===")
75+
for (name, f) in loop_variants
76+
R = make_R(); f(R)
77+
relerr = maximum(abs, R .- Rref) / maximum(abs, Rref)
78+
ok = relerr < 1e-8
79+
@printf(" %-20s %s relerr=%.3e\n", name, ok ? "OK " : "LOOSE", relerr)
80+
end
81+
82+
# ----------------------------------------------------------------------------
83+
# Timing
84+
# ----------------------------------------------------------------------------
85+
Rb = make_R()
86+
87+
println("\n=== Loop-only timing (pre-factored matrices passed in) ===")
88+
results = Tuple{String,Float64}[]
89+
for (name, f) in loop_variants
90+
t = @belapsed $f($Rb)
91+
push!(results, (name, t))
92+
@printf(" %-20s %8.2f µs\n", name, t*1e6)
93+
end
94+
95+
println("\n=== Full-call timing (factorization included, as real freqresp usage) ===")
96+
tb = @belapsed ControlSystemsBase.freqresp!($Rb, $G, $w)
97+
@printf(" %-20s %8.2f µs\n", "V0 baseline", tb*1e6)
98+
tf = @belapsed freqresp_serial_full!($Rb, $G, $w)
99+
@printf(" %-20s %8.2f µs\n", "V1 serial (full)", tf*1e6)
100+
101+
# --- Summary table sorted by loop-only time ---
102+
println("\n=== Summary (loop-only, fastest first) ===")
103+
sort!(results, by = x -> x[2])
104+
fastest = results[1][2]
105+
for (name, t) in results
106+
@printf(" %-20s %8.2f µs (%.2fx vs fastest)\n", name, t*1e6, t/fastest)
107+
end
108+
@printf("\n Full call baseline (V0): %.2f µs — factorization dominates at this size.\n", tb*1e6)
109+
println(" Threads used: $NTH")

0 commit comments

Comments
 (0)