Skip to content

OptimizationMOI: const f in MOIOptimizationNLPEvaluator to avoid large field copies - #1404

Merged
ChrisRackauckas merged 1 commit into
SciML:masterfrom
ChrisRackauckas-Claude:opt-moi-const-f
Oct 10, 2026
Merged

ChrisRackauckas merged 1 commit into
SciML:masterfrom
ChrisRackauckas-Claude:opt-moi-const-f

Conversation

@ChrisRackauckas-Claude

@ChrisRackauckas-Claude ChrisRackauckas-Claude commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

MOIOptimizationNLPEvaluator is a mutable struct that stores the instantiated OptimizationFunction inline as f::F. At n=10 with AutoForwardDiff + Hessian, sizeof(ev.f) is ~39 KB (DI Hessian preps), so every evaluator.f load from the mutable container copied the whole field on Julia 1.12. Declaring const f::F (supported since Julia 1.8; OptimizationMOI compat is julia = "1.10") lets the compiler elide those copies. Grep confirms nothing reassigns evaluator.f.

Before / after (Julia 1.12.7, JULIA_NUM_THREADS=1, taskset -c 95, @benchmark minima)

Rosenbrock + 3 constraints, AutoForwardDiff, Ipopt NLP callbacks. Identical callback values and Ipopt solutions before/after (n=2: 25 iters → [0.9999785, 0.9999569]; n=10: 12 iters → same u / objective).

callback (n=10) before (ns) after (ns)
eval_objective_gradient 1268 156
eval_constraint_jacobian 1347 240
eval_hessian_lagrangian 6240 5647

n=2 (small f): gradient 65→38 ns, jacobian 115→95 ns, hessian 439→408 ns.

Julia 1.10.12: callback values and Ipopt solves match the 1.12 results byte-for-byte on u/obj/iters. Absolute MOI-callback times on 1.10 were ~µs-scale both before and after in this env (other overhead appears to dominate; const still compiles and is correct).

Test group tails

  • OPTIMIZATION_TEST_GROUP=Core (Julia 1.12, lib/OptimizationMOI): Core | 40 Pass, 2 Broken, 42 Total 4m43.3s; Testing OptimizationMOI tests passed
  • OPTIMIZATION_TEST_GROUP=QA (Julia 1.12): Quality Assurance | 20 Pass, 1 Broken, 21 Total 2m30.7s; Testing OptimizationMOI tests passed
  • Runic / typos on lib/OptimizationMOI/src/nlp.jl: exit 0 (diff is the single const token; file was already Runic-clean on master)

Not verified

  • Full monorepo GROUP=All / other sublibraries
  • Downstream packages beyond OptimizationMOI Core + QA
  • Whether making additional large fields (reinit_cache, matrix buffers) const would help further
  • A production Ipopt wall-time win at large n (callback microbenchmarks only; solve time was already dominated by Ipopt itself)

Reviewer pushback

  • Is const f the right layer, or should hot paths use getfield(evaluator, :f) and leave the field mutable for theoretical reassignment through MOIOptimizationNLPCache’s setproperty!?
  • Should other large inline fields on the same mutable evaluator get the same treatment in a follow-up?
  • Julia 1.10 did not show the same microbenchmark win here; confirm whether that matches expectations given the existing getproperty override.

Please ignore until reviewed by @ChrisRackauckas.

Behavior note: because MOIOptimizationNLPCache's setproperty! forwards to the evaluator, cache.f = g now throws (const field). Nothing in the repo assigns it; reinit! only touches reinit_cache.

Risk assessment

  • Risk: low
  • Blast radius: OptimizationMOI NLP evaluator field layout only; f was never reassigned
  • Evidence: 1.12 microbenchmarks + identical Ipopt solutions on 1.12 and 1.10; Core/QA green
  • Independent review: Claude Code (claude-opus-5-5) MERGE, risk low (confidence high): Julia compat 1.10 allows const fields; nothing in the repo reassigns f; n=10 eval_objective_gradient 1161 -> 226 ns, constraint_jacobian 1249 -> 292 ns, hessian_lagrangian 6214 -> 5522 ns; Ipopt n=2 and n=10 results identical; OptimizationMOI Core passes.
  • Merge: needs review

🤖 Generated with Cursor Agent 2026.10.01-14929f9 (model: unknown (Cursor auto)), transcript /home/crackauc/sandbox/goals/performance/jobs/opt/opt-moi/log.txt on amdci2; orchestrated by Claude Code (claude-opus-5-5[1m]) https://claude.ai/code/session_01LPHREnnonfLg1VcE1EJovv

Made with Cursor

Independent review: Devin Fusion (fusion-claude-opus-5-5-high-sidekick-swe-2-medium) rated it low, verdict MERGE: #1404 (comment)

MOIOptimizationNLPEvaluator stores the instantiated OptimizationFunction
inline; at n=10 sizeof(f) is ~39 KB, so every evaluator.f load from the
mutable struct copied the whole field on Julia 1.12. Declaring const f::F
(Julia >= 1.8; package compat is 1.10) lets the compiler elide those copies.
Nothing reassigns evaluator.f.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Cursor Agent <noreply@cursor.com>
Agent-Harness: Cursor Agent 2026.10.01-14929f9
Agent-Model: unknown (Cursor auto)
Agent-Session: local session, transcript /home/crackauc/sandbox/goals/performance/jobs/opt/opt-moi/log.txt on amdci2
@ChrisRackauckas

Copy link
Copy Markdown
Member

🤖 Automated comment from an AI agent running as @ChrisRackauckas — not written or reviewed by Chris.

Independent review (Devin CLI 3000.11.3, model fusion-claude-opus-5-5-high-sidekick-swe-2-medium): MERGE, risk low.

Full review

VERDICT: MERGE
RISK: low

PR: #1404. The diff is one token: lib/OptimizationMOI/src/nlp.jl:6 f::F → const f::F on the mutable MOIOptimizationNLPEvaluator. The PR body links no issue and no tracking issue.

Blocking findings

None.

Non-blocking findings

  1. Behavior change: cache.f = g now throws (confirmed by running). MOIOptimizationNLPCache's setproperty! (nlp.jl:45-50) forwards to setfield!(cache.evaluator, :f, x). On master, cache2.f = cache2.f succeeds. With the PR it throws ErrorException: setfield!: const field .f of type MOIOptimizationNLPEvaluator cannot be changed, on both 1.12.7 and 1.10.12. The PR body discloses this. On master the setter was already limited to a value of the exact same concrete type F, because setfield! does not convert. I searched lib/ (including OptimizationBase) and ~/.julia/packages/SciMLBase for assignments to cache.f/.f = and found none, so no in-repo or SciMLBase caller depends on the setter. The setter is undocumented internal behaviour, so I don't consider this a SemVer break. Maintainers may still want a patch bump of lib/OptimizationMOI/Project.toml (1.4.2) when releasing; the PR doesn't include one.
  2. No regression test (from reading). This is a pure performance/layout change and the Core suite does exercise every callback. A one-line @test isconst(OptimizationMOI.MOIOptimizationNLPEvaluator, :f) would stop someone from silently reverting it. That would be nice to have, not required.
  3. The author's suggested follow-up (making other large inline fields such as reinit_cache, J, H const) is out of scope here and correctly left out. evaluator.iteration += 1 (nlp.jl:258) mutates iteration, so that field must stay mutable; I also checked that cache.iteration = 3 still works with the PR. reinit! mutates the ReInitCache object, not the evaluator field, so whether that field could be const is a separate question.

What I ran

All runs were on this Mac (M2 Max) with -t1. I used scratch envs outside the checkout: env_pr develops the PR's lib/OptimizationBase + lib/OptimizationMOI, and env_master develops the same libs taken from git archive origin/master. The probe is scratch/probe.jl. Rosenbrock (n=2, n=10) + 3 constraints, AutoForwardDiff, Ipopt. It runs MOI.initialize([:Grad,:Jac,:Hess]), evaluates objective/gradient/Jacobian/Hessian-of-Lagrangian at a fixed x, uses @benchmark minima, does solve!, does reinit! + solve!, and tries the cache.f setter.

  • Correctness, confirmed by running: on Julia 1.12.7, every printed value is byte-identical between PR and master: obj, G, J, H, sol.u, objective, retcode, iteration count, and the reinit solve (diff of the outputs after filtering timing lines: only precompile log lines differ). I hand-checked the n=2 Hessian against the analytic Rosenbrock + constraint Hessian at x=(0.1, 0.9), σ=1, μ=(0.3, 0.7, 1.1): [-345.4, -39.3, 200.6] is correct. The n=2 and n=10 solves converge to the all-ones minimizer with retcode Success. The 1.10.12 PR run gives the same values as 1.12.

  • Performance claim, reproduced on 1.12.7 (minimum ns, master → PR). sizeof(ev.f) = 920 B at n=2 and 38 936 B at n=10, which matches the body's "~39 KB".

    • n=10: gradient 773 → 109, Jacobian 791 → 172, Hessian 5486 → 4924
    • n=2: gradient 37 → 16, Jacobian 86 → 82, Hessian 333 → 299

    The absolute numbers differ from the body's (it ran on different hardware), but the direction and magnitude match.

  • OPTIMIZATION_TEST_GROUP=Core julia +1.12 --project=. -e 'using Pkg; Pkg.test()' on a copy of the PR's lib/ ran to Core | 40 Pass, 2 Broken, 42 Total 2m29.2s, Testing OptimizationMOI tests passed. That matches the body's 40/2/42. The 2 Broken are pre-existing, since the PR touches no test file.

  • julia +1.12 --project=@runic -m Runic --check lib/OptimizationMOI/src/nlp.jl exited 0, and typos over the diff exited 0.

  • Commit trailers carry Agent-Harness/Model/Session, and the PR body carries the attribution. The body ends with "Please ignore until reviewed by @ChrisRackauckas" instead of opening with the required first-line banner. That is a formatting nit and doesn't affect the code.

What I did not verify

  • The QA group (Aqua/JET/ExplicitImports). I did not rerun it. A const field annotation cannot plausibly affect it, and the body reports 20 Pass, 1 Broken.
  • Master's timings on Julia 1.10. I only ran the PR on 1.10 (it allocates and runs at µs scale there). So I can't confirm the body's claim that 1.10 showed no win before or after.
  • Core on Julia 1.10 or the pre channel. I only checked that the const field compiles and gives correct results on 1.10.12.
  • Downstream packages and other sublibraries. A grep found no assignment of .f on this evaluator or cache in lib/ or SciMLBase.

🤖 Posted by an AI agent — harness: Devin CLI 3000.11.3 (review), Claude Code 2.1.285 (posting) · model: fusion-claude-opus-5-5-high-sidekick-swe-2-medium
Conversation: local Claude session 3c311569-dda6-4687-bc9f-65febb212c33 (fleet master)

@ChrisRackauckas
ChrisRackauckas marked this pull request as ready for review October 10, 2026 18:44
@ChrisRackauckas
ChrisRackauckas merged commit ce2b3a0 into SciML:master Oct 10, 2026
54 of 56 checks passed
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