Skip to content

Avoid extra copies of functions in copy_to - #2957

Open
blegat wants to merge 12 commits into
masterfrom
bl/unsafe
Open

blegat wants to merge 12 commits into
masterfrom
bl/unsafe

Conversation

@blegat

@blegat blegat commented Feb 27, 2026 •

Copy link
Copy Markdown
Member

It seems wasteful to copy the functions in copy_to before it goes through the IndexMap when we also copy the function when it arrives in the model because we don't allow a MOI.ModelLike to keep a copy of the function.
For most implementation of MOI.ModelLike, MOI.ConstraintFunction does create a new copy but for MOI.Utilities.VectorOfConstraints, it is actually making a copy.
What's a bit inconsistent is that MOI.Utilities.ObjectiveContainer does not create a copy of the objective function at the moment. With this PR, the way we handle objective and constraints are not consistent.

  • docs
  • tests
  • benchmark

Removes the need for #2803

Closes #2803

@blegat

blegat commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Here is Opus 5.5's opinion on this: Right now there are three different rules on master:

copy when the function comes in copy when it goes out
VectorOfConstraints yes (canonical) yes
ObjectiveContainer yes no
Bridges (SplitInterval, the MILP ones) no yes

The bridges look like they only cover the mutation path that #2328's test checks. The objective container covers only the other one.

Proposal: a model owns what it stores, and it only has to copy what it could mutate.

  1. On the way in (add_constraint, set): the model calls copy_if_modifiable (or canonical, which already makes that copy) before storing. This is the copy that matters. MOI.modify mutates the stored function in place, so without it:

    • f = x + y; add_constraint(m, f, LessThan(1)); add_constraint(m, f, GreaterThan(0)); modify(m, c1, ...) would silently change c2 as well.
    • A copy_to with an identity map would make the destination share functions with the source.

    Most callers (JuMP, map_indices, bridges) pass fresh objects, but the plain MOI code above is too natural to forbid.

  2. On the way out (get(ConstraintFunction), get(ObjectiveFunction)): the caller gets an object it owns, so also copy_if_modifiable. A user doing get, then modify_function!, then set is a reasonable pattern, and it's what [Bridges] fix some getters of ConstraintFunction and add better tests #2328 tests.

  3. Internal code that only reads uses a borrowing getter, which is what Avoid extra copies of functions in copy_to #2957's UnsafeConstraintFunction / UnsafeObjectiveFunction are. Its contract is: the caller must not mutate the result or keep it. copy_to is the main user. It reads, then map_indices builds a new object, or the destination copies it anyway under rule 1.

Why copy in both directions isn't silly:

  • Since copy_if_modifiable, only SAF/SQF/VAF/VQF are copied. Those copies are linear in size and cheap next to a solve. SNF, VectorOfVariables and VariableIndex are never copied in either direction.
  • The double copy that actually wastes time is get-then-add inside copy_to, and rule 3 removes exactly that.
  • The rule is one sentence that a new model or bridge author can apply without thinking: "copy what you store and what you return, unless the function isn't modifiable."

Changes needed to apply it:

  • ObjectiveContainer getters: add copy_if_modifiable on the way out. This is the inconsistency Avoid extra copies of functions in copy_to #2957's description points out.
  • Bridges that store the user's function: copy on the way in in bridge_constraint and set(ConstraintFunction). That means SplitInterval and the MILP ones above, and any other bridge with a f/func field (I haven't listed those yet).
  • Write the contract in the docstrings of ConstraintFunction and ObjectiveFunction, next to the "## Copies" sections added by bl/copy_nl.

The alternative is to make add_constraint take ownership, so the caller must not touch f afterwards and nothing is copied on the way in. That saves one linear copy per constraint, but it turns the two-constraints-sharing-f case into silent corruption, so I'd keep copying on the way in.

@blegat

blegat commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

So that confirms the motivation for this PR, we should copy both in add_constraint and get(_, ::ConstraintFunction, _) with copy_if_modifyable. That's already what we are doing in most case, see table in previous comment. Then we add UnsafeConstraintFunction so that we don't get useless double copying in copy_to

@blegat

blegat commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Another inconsistency: UniversalFallback returned the objective it stores without a copy. It now returns copy_if_modifiable of it.

blegat added 12 commits November 6, 2026 17:10
* StructOfConstraints did not forward UnsafeConstraintFunction so
  Utilities.Model returned a copy.
* The ObjectiveContainer getter of ObjectiveFunction calling
  UnsafeObjectiveFunction was never called as the existing getter of
  ObjectiveFunction was more specific. The existing getter now implements
  UnsafeObjectiveFunction instead.
* UniversalFallback now returns a copy of the objective function it stores
  for ObjectiveFunction.
* Use copy_if_modifiable instead of copy.
Bridges storing the function of the constraint they bridge now only
implement UnsafeConstraintFunction and the copy is done by a fallback for
ConstraintFunction in bridge.jl. Bridges building a new function only
implement ConstraintFunction and UnsafeConstraintFunction falls back to it.
SplitIntervalBridge forwards UnsafeConstraintFunction to its inner
constraints.

The bridge optimizer forwards UnsafeConstraintFunction to the inner model
and to the bridges.

UnsafeConstraintFunction has the same value as ConstraintFunction except
that it may not be an independent copy so MockOptimizer now maps its
indices as for ConstraintFunction.
* The fallbacks of ConstraintFunction and UnsafeConstraintFunction for
  bridges calling each other created an infinite recursion for bridges
  implementing neither of them, which is supported with
  cannot_unbridge = true. The bridge optimizer now gets ConstraintFunction
  from the bridges and UnsafeConstraintFunction throws the same
  GetAttributeNotAllowed{ConstraintFunction} as before for bridges
  implementing neither.
* Fix ambiguities of the ConstraintFunction fallback for bridges with the
  getters of Variable.SetMapBridge and AbstractFunctionConversionBridge.
* The fallback of UnsafeObjectiveFunction is now a get_fallback as it was
  ambiguous with the getters of model attributes of wrappers such as
  MockOptimizer and CachingOptimizer. The bridge optimizer implements
  UnsafeObjectiveFunction with ObjectiveFunction as the objective function
  may be bridged.
Replace the unreachable ConstraintFunction model-attribute method with an ObjectiveFunction method that reads UnsafeObjectiveFunction while setting the regular objective attribute.

Test affine, quadratic, and nonlinear objectives with identity and permuted index maps, including independent storage and modification for modifiable functions.

Validation: all nine test files changed by the PR pass (138 test groups); temporary line markers confirm the new path executes for all three function types. JuliaFormatter 2.14.7 leaves no formatting diff.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant