Skip to content

Performance & Type Stability ​

This guide explains how CTModels keeps its runtime-critical code fast, and how a contributor can check that a change has not introduced a regression.

The whole approach rests on one distinction.

The one principle: hot path vs. setup path ​

CTModels code falls into two categories:

The rule of thumb for contributors: keep the hot path inferable; do not worry about the mutable builder's dynamism. When in doubt, the tools below tell you which side of the line you are on.

The toolbox ​

ToolWhat it doesWhen to reach for it
@code_warntype f(args...)Colored dump of inferred types for one call; red marks instability.First local look at a single function in the REPL.
Cthulhu.@descend f(args...)Interactive, navigable version of the above; descend into callees.Finding where deep in a call chain an instability originates.
JET.@report_opt f(args...)Reports runtime-dispatch / optimization failures for one concrete call.The at-a-glance stability check used on this page.
JET.report_package(CTModels)Whole-package correctness scan (undefined names, method errors).Catching latent bugs; run manually (see below) — not wired into the test suite as a gate.
Test.@inferred f(args...)Fails unless the call is type-stable.Locking a fixed hot-path function against future regressions in a test.

JET is a dev/test/docs dependency only — it is not a runtime dependency of CTModels.

Checking the hot path at a glance ​

JET.@report_opt inspects a concrete call and prints No errors detected when the call is free of runtime dispatch. The blocks below run live at documentation build time, so if a change ever destabilises one of these hot-path entry points, this page's build surfaces it.

First, build the objects we will exercise:

julia
using CTModels
using JET

f_const = CTModels.Components.ConstantInTime(1.0)
f_coerced = CTModels.Components.CoercedTrajectory(t -> [2t], only)
duals_fn = t -> [10.0 * t, 20.0 * t, 30.0 * t]
f_slice = CTModels.Solutions.DualSlice(duals_fn, 2)
f_proj = CTModels.Models.BoxProjection{:state}(2)

Evaluating a constant-in-time function (state/control interpolant baseline):

julia
JET.@report_opt f_const(0.5)
No errors detected

Evaluating a coerced trajectory (the wrapper behind every sol.state, sol.control, sol.costate call):

julia
JET.@report_opt f_coerced(0.5)
No errors detected

Extracting a dual by label (called at many time points when plotting or post-processing a solution's multipliers):

julia
JET.@report_opt f_slice(0.5)
No errors detected

Projecting a box constraint (state/control/variable slot selection):

julia
JET.@report_opt f_proj(nothing, [10.0, 20.0, 30.0], nothing, nothing)
No errors detected

All four report No errors detected: the repeated-call path is stable.

What is enforced automatically ​

Rather than a whole-package JET.test_package gate, the test suite locks in stability precisely on the hot-path calls shown above, via JET.@test_opt:

julia
# test/suite/meta/test_code_quality.jl
JET.@test_opt target_modules = (CTModels,) f_const(0.5)
JET.@test_opt target_modules = (CTModels,) f_coerced(0.5)
# ... and so on for the other hot-path calls

The complementary allocation contract — a wrapper call must allocate exactly what the raw computation it wraps allocates, and accessor reads must allocate nothing — lives in test/suite/meta/test_performance.jl (BenchmarkTools.@ballocated guards), and Test.@inferred guards sit next to each fixture in the ordinary test files (e.g. test/suite/components/test_components.jl).

To run a whole-package correctness scan interactively on a fresh checkout:

julia
using CTModels, JET
JET.report_package(CTModels; target_modules=(CTModels,))

Known, acceptable dynamism ​

Running report_package on CTModels reports a number of "no matching method" findings. These are expected and do not indicate a regression — they are not wired into the test suite as a gate for exactly this reason:

  • PreModel field access. Building.PreModel is a mutable struct whose state, times, dynamics, … fields are typed Union{T,Nothing} — filled in incrementally as state!, time!, dynamics!, etc. are called, and validated with precondition checks (Core.@ensure) before being read. JET's union-split analysis reports a Nothing branch for every such read; that branch is never reached at runtime because the precondition check throws first. This is setup-path code, deliberately dynamic by construction — not a defect.

  • build/build_solution composition. The functions that assemble a Model or Solution from a PreModel/raw solver output inherit the same Union{T,Nothing} dynamism while validating and normalising their inputs. Once a Model/Solution is built, its accessors return concrete, non-Nothing-parameterised types — the dynamism never reaches the hot path.

Investigating a regression ​

If a hot-path check above starts reporting dispatch, drill in locally:

julia
using CTModels
f_coerced = CTModels.Components.CoercedTrajectory(t -> [2t], only)

# 1. Quick look
using InteractiveUtils
@code_warntype f_coerced(0.5)

# 2. Navigate the call chain to the root cause
using Cthulhu
@descend f_coerced(0.5)

Once fixed, lock the result with a stability test so it cannot silently regress:

julia
using Test
@inferred f_coerced(0.5)

See Also ​

  • Getting Started: the PreModel → build → Model pipeline this guide's "hot path vs. setup path" distinction is built on.