Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor CyIpopt interface to subclass cyipopt.Problem #2760

Merged
merged 33 commits into from
Apr 25, 2023

Conversation

Robbybp
Copy link
Contributor

@Robbybp Robbybp commented Mar 2, 2023

Summary/Motivation:

In mechmotum/cyipopt#182, I am working on an interface to let us call GetIpoptCurrentIterate and GetIpoptCurrentViolations during an intermediate callback. The non-hack way to do this without augmenting CyIpopt's callback signature is to add methods on cyipopt.Problem. This means that our callback in Pyomo will need to know about the cyipopt.Problem object, which can be achieved by having our "interface object" subclass cyipopt.Problem rather than passing our interface object to cyipopt.Problem when it is constructed.

This is a very small change in terms of lines of code, as cyipopt.Problem will automatically look for methods on self, so we don't need a new wrapper class, if it is not given a problem_obj (our CyIpoptProblemInterface) during construction.

Accidentally built this on top of #2759...

Changes

  • Update CyIpoptProblemInterface to subclass cyipopt.Problem
  • Call super().__init__ to initialize the cyipopt.Problem (if we forget this on any derived class, we segfault!)
  • Use the CyIpoptProblemInterface instead of creating a new cyipopt.Problem during solve methods

Legal Acknowledgement

By contributing to this software project, I have read the contribution guide and agree to the following terms and conditions for my contribution:

  1. I agree my contributions are submitted under the BSD license.
  2. I represent I am authorized to make the contributions and grant the license. If my employer has rights to intellectual property that includes these contributions, I represent that I have received permission to make contributions and grant the required license on behalf of that employer.

@@ -148,7 +148,7 @@ def _cyipopt_importer():
'Internal_Error': TerminationCondition.internalSolverError,
}

class CyIpoptProblemInterface(object, metaclass=abc.ABCMeta):
class CyIpoptProblemInterface(cyipopt.Problem, metaclass=abc.ABCMeta):
Copy link
Contributor Author

@Robbybp Robbybp Mar 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fails if cyipopt is not available. We could get around this with

cyipopt_Problem = cyipopt.Problem if cyipopt_available else object

at the top of the file, but then we get a confusing error message when we try to call CyIpoptProblemInterface.solve. Maybe do this but raise an error in __init_subclass__ if we're not an instance of cyipopt.Problem?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using cyipopt_Problem as you suggest, then raising a sensible exception in __init__ if not cyipopt_available?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not bad, we just have to remember to call the right combination of super().__init__s in subclasses. But I could move the cyipopt.Problem.__init__ call into CyIpoptProblemInterface.__init__, so subclass developers only have to remember to call one super().__init__.

@Robbybp
Copy link
Contributor Author

Robbybp commented Mar 3, 2023

Test failures (on windows and linux/conda) are 'cyipopt' : Unexpected module found in 5 slowest-loading TPL modules, so I added cyipopt to the list of potentially slow-loading modules in test_tpl_import_time. Now, locally, I get failures like this:

    self.assertLess(tpl_time / total, 0.65)
AssertionError: 0.7089268883802343 not less than 0.65

I'm a little hesitant to adjust this hand-coded tolerance when I don't know why import time has suddenly increased. Maybe

cyipopt_Problem = cyipopt.Problem if cyipopt_available else object

is taking a ton of time during import?

Comment on lines 155 to 156
if cyipopt_available:
ref.add('cyipopt')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to add cyipopt to this list: this is indicating that cyipopt is being unconditionally imported by pyomo.environ. We should re-think the imports so that we don't have to update this list.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this because PyomoCyIpoptSolver is registered as a plugin so it can be accessed via pyo.SolverFactory("cyipopt")?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes: pyomo.environ imports pyomo.contrib.pynumero.plugins which imports pyomo.contrib.pynumero.algorithms.solers.cyipopt_solver

Copy link
Contributor Author

@Robbybp Robbybp Mar 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried using a deferred import (via attempt_import) for pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver in pynumero.plugins, but that didn't change anything, nor did moving from .algorithms.solvers.cyipopt_solver import PyomoCyIpoptSolver to within the load function of pynumero.plugins.

Do you have any ideas?

@jsiirola
Copy link
Member

jsiirola commented Mar 3, 2023

I'm a little hesitant to adjust this hand-coded tolerance when I don't know why import time has suddenly increased. Maybe

cyipopt_Problem = cyipopt.Problem if cyipopt_available else object

is taking a ton of time during import?

Yes - I think the thing that changed is that we now cast cyipopt_available to a bool at the module scope. Casting cyipopt_available to bool is one of the ways to trigger the deferred module import (along with accessing an attribute on the cyipopt "module").

@jsiirola
Copy link
Member

OK - I think I know how to solve the chicken & egg issue here:

  • split the ProblemInterface classes into a separate module from the solver interfaces (maybe .pynumero.interfaces.cyipopt_problem?)
  • Then, the solver interface can attempt_import the problem interface module, e.g.:
     cyipopt_problem, _ = attempt_import('pyomo.contrib.pynumero.interfaces.cyipopt_problem')
  • then the PyomoCyIpoptSolver can just problem = cyipopt_problem.CyIpoptNLP(...

That should prevent importing cyipopt until the solver interface is actually instantiated (actually, it would be deferred all the way until the first solve()).

@Robbybp
Copy link
Contributor Author

Robbybp commented Mar 14, 2023

  • split the ProblemInterface classes into a separate module from the solver interfaces (maybe .pynumero.interfaces.cyipopt_problem?)
  • Then, the solver interface can attempt_import the problem interface module, e.g.:
     cyipopt_problem, _ = attempt_import('pyomo.contrib.pynumero.interfaces.cyipopt_problem')
  • then the PyomoCyIpoptSolver can just problem = cyipopt_problem.CyIpoptNLP(...

Thanks for the suggestion, this works for me locally.

Comment on lines +44 to +62
# These attributes should no longer be imported from this module. These
# deprecation paths provide a deferred import to these attributes so (a) they
# can still be used until these paths are removed, and (b) the imports are not
# triggered when this module is imported.
relocated_module_attribute(
"cyipopt_available",
"pyomo.contrib.pynumero.interfaces.cyipopt_interface.cyipopt_available",
"6.5.1.dev0",
)
relocated_module_attribute(
"CyIpoptProblemInterface",
"pyomo.contrib.pynumero.interfaces.cyipopt_interface.CyIpoptProblemInterface",
"6.5.1.dev0",
)
relocated_module_attribute(
"CyIpoptNLP",
"pyomo.contrib.pynumero.interfaces.cyipopt_interface.CyIpoptNLP",
"6.5.1.dev0",
)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I commonly import cyipopt_available from this module, so I added these relocation warnings so these imports will still be available. This seems not to increase import time, judging by test_environ.py still passing locally.

@Robbybp
Copy link
Contributor Author

Robbybp commented Mar 14, 2023

        solver = pyo.SolverFactory('cyipopt')
        solver.config.options = {'hessian_approximation':'limited-memory',
                                 'nlp_scaling_method': 'user-scaling',
                                 'output_file': '_cyipopt-external-greybox-scaling.log',
                                 'file_print_level':10,
                                 'max_iter': 0}
        status = solver.solve(m, tee=False)
    
        with open('_cyipopt-external-greybox-scaling.log', 'r') as fd:
            solver_trace = fd.read()
>       os.remove('_cyipopt-external-greybox-scaling.log')
E       PermissionError: [WinError 32] The process cannot access the file because it is being used by another process: '_cyipopt-external-greybox-scaling.log'

@jsiirola Actually, these are the options sent to Ipopt, so this file is created by Ipopt and probably not closed until the IpoptProblem (or whatever the name is) C struct is freed, which is whenever CyIpoptNLP.__dealloc__ is called, which I assume is when it gets garbage collected. This may be sufficient reason to implement a workaround in CyIpopt to avoid needing to subclass Problem to access the get_current_iterate functionality.

I just pushed a commit with gc.collect() before these calls to os.remove to test this hypothesis.

@jsiirola
Copy link
Member

@Robbybp, I think we can resolve the Windows test failures by calling cyipopt.Problem.close() before attempting to delete the log file that cyipopt generated. The catch is that the Problem is private from the point of view of the tests (i.e., _problem). We could just access the private attribute in the test (i.e., solver._problem.close()), or we could make a more public API for releasing the cyipopt object (possibilities include promoting close(), or making a context manager that calls close() on __exit__()).

Thoughts?

@Robbybp
Copy link
Contributor Author

Robbybp commented Mar 18, 2023

@jsiirola Just pushed a commit that calls close() before attempting to remove the file. If this works, I'll work on a public API.

@codecov
Copy link

codecov bot commented Mar 21, 2023

Codecov Report

Patch coverage: 90.37% and project coverage change: +0.01 🎉

Comparison is base (2128df4) 87.02% compared to head (74aadec) 87.03%.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2760      +/-   ##
==========================================
+ Coverage   87.02%   87.03%   +0.01%     
==========================================
  Files         763      764       +1     
  Lines       87246    87268      +22     
==========================================
+ Hits        75926    75956      +30     
+ Misses      11320    11312       -8     
Flag Coverage Δ
linux 84.04% <90.37%> (+<0.01%) ⬆️
osx 73.56% <45.98%> (+0.01%) ⬆️
other 84.22% <90.37%> (+<0.01%) ⬆️
win 81.56% <90.37%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Impacted Files Coverage Δ
...o/contrib/pynumero/interfaces/cyipopt_interface.py 88.81% <88.81%> (ø)
...trib/pynumero/algorithms/solvers/cyipopt_solver.py 80.23% <100.00%> (-3.16%) ⬇️
.../pynumero/algorithms/solvers/implicit_functions.py 96.55% <100.00%> (+0.01%) ⬆️
...b/pynumero/algorithms/solvers/pyomo_ext_cyipopt.py 89.75% <100.00%> (+0.06%) ⬆️

... and 2 files with indirect coverage changes

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

☔ View full report in Codecov by Sentry.
📢 Do you have feedback about the report comment? Let us know in this issue.

@blnicho blnicho self-requested a review March 22, 2023 19:53
@Robbybp
Copy link
Contributor Author

Robbybp commented Mar 28, 2023

Highlights:

  • Updated CyIpoptProblemInterface to subclass cyipopt.Problem
  • Added a CyIpoptProblemInterface.__init__ method that calls cyipopt.Problem.__init__
  • Split cyipopt_solver.py into two files so PyomoCyIpoptSolver can be imported (and registered) even if cyipopt is not available
  • Add calls to CyIpoptNLP.close in a few tests to force deallocation Ipopt data structures so that Ipopt's log file can be removed on windows

@carldlaird can you take a look at this?

Comment on lines +289 to +292
# Call CyIpoptProblemInterface.__init__, which calls
# cyipopt.Problem.__init__
super(PyomoExternalCyIpoptProblem, self).__init__()

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that this __init__ call, which wasn't previously necessary, is now necessary in subclasses of CyIpoptProblemInterface to make sure that the cyipopt.Problem get initialized so we don't segfault when we try to solve it later.

In this way, this PR could break some user code if they are subclassing CyIpoptProblemInterface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am thinking of adding an __initialized flag to CyIpoptProblemInterface.__init__ so we can raise a warning/error if solve is called when cyipopt.Problem hasn't been initialized.

Comment on lines 180 to 183
with open('_cyipopt-pyomo-ext-scaling.log', 'r') as fd:
solver_trace = fd.read()
cyipopt_problem.close()
os.remove('_cyipopt-pyomo-ext-scaling.log')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call to cyipopt.Problem.close is now necessary on windows if we try to remove a file too early (I think before the garbage collector has deallocated the cyipopt.Problem, but my testing was inconclusive).

This is another way this PR could break a user's code.

@Robbybp
Copy link
Contributor Author

Robbybp commented Apr 22, 2023

I've added a _problem_initialized flag that we set in CyIpoptProblemInterface.__init__, then check in CyIpoptProblemInterface.solve. In this way, if somebody overrides __init__ but doesn't call super().__init__, they will get an error when we attempt to call solve in e.g. PyomoCyIpoptSolver. This should prevent segfaults in this case, unless the user has also overridden solve, in which case they are on their own.

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.

4 participants