Skip to content

Trajectory linearization: avoid per-timepoint recompilation; require op for loop-opening parameters#4679

Merged
AayushSabharwal merged 2 commits into
as/linearize-opfrom
bagge/linearize-trajectory-perf
Jun 30, 2026
Merged

Trajectory linearization: avoid per-timepoint recompilation; require op for loop-opening parameters#4679
AayushSabharwal merged 2 commits into
as/linearize-opfrom
bagge/linearize-trajectory-perf

Conversation

@baggepinnen

@baggepinnen baggepinnen commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Targets as/linearize-op (#4443).

While exercising this branch through ControlSystemsMTK.trajectory_ss (linearizing a gain-scheduled closed loop at ~800 points along a trajectory), the multi-timepoint path was ~340 ms/point with ~92% of the time spent in the Julia compiler, recompiling on essentially every iteration. This PR addresses the two causes (points 1 and 2 from my review comment in #4443).

1. Operating-point values are boxed into SymbolicT, forcing per-value codegen

_build_op_from_solution returns a Dict{SymbolicT, SymbolicT}, so every numeric value is stored as a symbolic constant. The op-application loop then took the symbolic_type(v) != NotSymbolic() branch for every entry and called getu(prob, v)(prob). For a constant, getu builds an observed function with the value baked into generated code, so a fresh RuntimeGeneratedFunction is compiled per distinct value — i.e. a recompile at almost every time point. (Confirmed empirically: replaying identical operating points cost 268 ms/pt cold but 33.8 ms/pt warm — the cost tracks the values.)

Fixed by _resolve_op_value, which unwraps symbolic constants to plain numbers/arrays and only routes genuinely symbolic expressions (e.g. op = Dict(ρ => A/K)) through getu.

2. The problem and setters were rebuilt every iteration

__linearize_multiple_op_barrier called the high-level per-point linearize in a loop, reconstructing the LinearizationProblem and rebuilding all setu accessors each iteration. It now builds the problem and setters once (the op keys are identical across time points) and per point only updates values via the cached setters and mutates .t, as the LinearizationProblem docstring already intends.

Results

Gain-scheduled closed loop (7 states, 21 op entries, 200 points, loop_openings active):

per point
before ~340 ms
after ~3.6 ms (~95×)

Results are bit-identical to the per-point scalar LinearizationOpPoint path (max abs diff = 0 over sampled indices). The remaining per-point cost is the legitimate initialization solve + Jacobians, not compilation.

The single-point linearize op-application now shares _resolve_op_value, so the same speedup benefits any caller whose op values are numeric, and the symbolic-op case (Dict(ρ => A/K)) is preserved (verified). Extended the LinearizationOpPoint testset with a per-point scalar-vs-vector equivalence check.

This is purely a performance change; it does not touch operating-point semantics. (Separately, the opened-loop-parameter freezing I mentioned in #4443 is unaffected and still wants the "error if not provided" change.)

🤖 Generated with Claude Code


Also: require an operating point for loop-opening parameters (correctness)

Second commit. When loop_openings is used, each opened analysis point's output is turned into a parameter (Break(ap, true)) whose operating-point value is not implied by the rest of the system — and is not present in a solution passed via LinearizationOpPoint. Previously it silently took a stale/default value; along a trajectory it was effectively frozen at the point used to build the linearization function rather than tracking the trajectory.

Now handle_loop_openings returns the variables it promotes to parameters; they are threaded into a new loop_opening_params field on LinearizationFunction, and linearize errors (listing them) if any is missing from the operating point. To supply them when the op comes from a solution, LinearizationOpPoint gains an optional op keyword merged into the per-timepoint op:

LinearizationOpPoint(sol, t; op = Dict(opened_signal => 0))

The op must be keyed by the parameter as it appears in the linearization system (root namespace stripped); the error message lists the exact names. Added a loop_openings require an operating point testset.

baggepinnen and others added 2 commits June 29, 2026 12:15
When linearizing along a trajectory with `LinearizationOpPoint(sol, tvec)`,
the multi-timepoint path was ~340 ms/point with ~92% of the time spent in the
Julia compiler, recompiling on essentially every iteration. Two causes:

1. `_build_op_from_solution` returns a `Dict{SymbolicT, SymbolicT}`, so every
   numeric operating-point value is stored as a symbolic constant. The
   op-application loop then took the `symbolic_type(v) != NotSymbolic()` branch
   for every entry and called `getu(prob, v)(prob)`. For a constant, `getu`
   builds an observed function with the value baked into generated code, so a
   fresh `RuntimeGeneratedFunction` is compiled per distinct value -> a
   recompile at almost every time point. Fixed by `_resolve_op_value`, which
   unwraps symbolic constants to plain numbers/arrays and only routes genuinely
   symbolic expressions through `getu`.

2. `__linearize_multiple_op_barrier` called the high-level per-point
   `linearize` in a loop, reconstructing the `LinearizationProblem` and
   rebuilding all setters every iteration. It now builds the problem and setters
   once (the op keys are identical across time points) and only updates values
   and mutates `.t` per point, as the `LinearizationProblem` docstring intends.

Measured on a gain-scheduled closed loop (7 states, 21 op entries, 200 points):
~340 ms/pt -> ~3.6 ms/pt (~95x), with results bit-identical to the per-point
scalar path. Added a per-point scalar-vs-vector equivalence check to the
`LinearizationOpPoint` testset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When `loop_openings` is used, each opened analysis point's output is turned
into a parameter (via `Break(ap, true)`). Its operating-point value is not
implied by the rest of the system, and in particular is not present in a
solution passed via `LinearizationOpPoint`. Previously such a parameter
silently took a stale/default value, which is especially wrong when
linearizing along a trajectory: the value was frozen at the point used to
build the linearization function rather than tracking the trajectory.

`handle_loop_openings` now returns the variables it turns into parameters;
these are threaded through `linearization_ap_transform` /
`get_linear_analysis_function` into a new `loop_opening_params` field on
`LinearizationFunction`. `linearize` errors (listing the offending
parameters) if any of them is missing from the operating point, instead of
using an implicit value.

To supply them when the operating point is derived from a solution,
`LinearizationOpPoint` gains an optional `op` keyword whose entries are merged
into the solution-derived operating point at every time point, e.g.
`LinearizationOpPoint(sol, t; op = Dict(opened_signal => 0))`.

Added a `loop_openings require an operating point` testset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@baggepinnen baggepinnen changed the title perf: avoid per-timepoint recompilation in trajectory linearization Trajectory linearization: avoid per-timepoint recompilation; require op for loop-opening parameters Jun 30, 2026
@AayushSabharwal AayushSabharwal merged commit 2881651 into as/linearize-op Jun 30, 2026
22 of 29 checks passed
@AayushSabharwal AayushSabharwal deleted the bagge/linearize-trajectory-perf branch June 30, 2026 06:44
baggepinnen added a commit to JuliaControl/ControlSystemsMTK.jl that referenced this pull request Jun 30, 2026
…ion docs

`trajectory_ss` now accepts an `op` keyword that is forwarded to
`ModelingToolkit.LinearizationOpPoint(sol, t; op)`, supplying operating-point
values that are not available from the solution. This is required by the
updated MTK linearization, which errors if a loop-opening parameter (the signal
turned into a parameter by `loop_openings`) is not assigned a value.

The batch-linearization tutorial now holds each opened signal at 0 via `op`,
and the text is updated to the new semantics: opening at `u` keeps the
scheduling variable connected (controller linearized along the trajectory and
fully isolated), opening only at `y` leaves the scheduling feedback in place
(controller still coupled to the plant), and opening `y`+`v` pins the
scheduling to 0 (a different controller). The previous
`controllersv == controllersu` test no longer holds and is replaced by a check
that opening at `u` yields a lower-order (isolated) controller than opening at
`y`.

Requires the corresponding MTK change (SciML/ModelingToolkit.jl#4679).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants