Skip to content

Make GDP enumeration lazy - #4031

Open
alvaroborras wants to merge 3 commits into
Pyomo:mainfrom
alvaroborras:fix/gdpopt-lazy-enumeration
Open

Make GDP enumeration lazy#4031
alvaroborras wants to merge 3 commits into
Pyomo:mainfrom
alvaroborras:fix/gdpopt-lazy-enumeration

Conversation

@alvaroborras

Copy link
Copy Markdown

Fixes #3953

Summary/Motivation:

gdpopt.enumerate previously materialized every discrete solution before checking the time and iteration limits. For large discrete spaces, this could exhaust available memory before solving began.

This change counts the discrete solution space without materializing it and requests solutions lazily after checking the configured limits. It preserves correct completion behavior and supports reusing the solver instance.

Changes proposed in this PR:

  • Enumerate discrete solutions lazily, checking time and iteration limits before requesting each solution.
  • Add concise regression tests for large discrete spaces, completion behavior, and solver reuse.

AI-Use Disclosure

  • AI tools were NOT used during the preparation of this PR

or

  • AI tools contributed to the development of this PR

    • AI tools generated documentation (including the PR description/comments, code comments, and/or Sphinx documentation)
    • AI tools generated tests (baselines, examples, and/or code)
    • AI tools generated code (apart from tests)

    Review process (select ONE):

    • Rewritten: All AI-generated content was rewritten by me before being committed.
    • Reviewed/verified: I retained AI-generated content and verified it before committing. Verification included (as applicable):
      • Ran the code and fixed issues
      • Added and ran tests
      • Checked correctness/logic of code and tests
      • Checked for alignment with the contribution guide
      • Considered security implications
    • As-is: AI-generated content was commited directly to the repository

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.

@blnicho
blnicho requested a review from emma58 September 1, 2026 19:03

@emma58 emma58 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thank you for this--I agree it's a good change since someone could always want to cut off something unreasonable at a time or iteration limit. I think the implementation could be simpler though: There's no need to calculate the number of discrete solutions, we can just iterate through the generator until we hit a termination condition or are done.

Comment thread pyomo/contrib/gdpopt/enumerate.py Outdated
Comment on lines +137 to +138
self.num_discrete_solns *= 2 ** len(non_indicator_boolean_vars) * math.prod(
max(0, index(v.ub) - index(v.lb) + 1) for v in discrete_vars

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

While it would make sense for the bounds of integer variables to be integer, Pyomo doesn't enforce that. Your call to index here will raise an error in that case. I think instead you should round correctly and cast the result to int.

Comment thread pyomo/contrib/gdpopt/enumerate.py Outdated
self.num_discrete_solns = len(discrete_solns)
for soln in discrete_solns:
# We will interrupt based on time limit or iteration limit:
for _ in range(self.num_discrete_solns):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

On second thought, there's no reason to calculate num_discrete_solutions ever. We could just iterate through the solutions in self._discrete_solution_iterator here.

@alvaroborras

Copy link
Copy Markdown
Author

Thanks for the feedback. I agree that precomputing num_discrete_solns is unnecessary. I’ve updated the implementation to iterate directly over _discrete_solution_iterator, so the index calculation (and the bounds issue you identified) is removed entirely.

I also removed the custom _log_current_state because its only difference from the base implementation was displaying iteration/num_discrete_solns. Without that count, the override would either reference a removed attribute or duplicate the base logger (and show AttributeError).

@alvaroborras
alvaroborras force-pushed the fix/gdpopt-lazy-enumeration branch from 55a60b9 to eb44fbf Compare September 2, 2026 08:16

@emma58 emma58 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks much better, thank you! On question though--I forgot that we overrode the progress logger to show progress towards complete enumeration. I think that may have been wise and we should add back in the calculation of how many solutions there are for that. Do you agree?

Comment on lines -96 to -110
# Override logging so that we print progress in terms of the number of
# iterations needed to fully enumerate the discrete space.
def _log_current_state(self, logger, subproblem_type, primal_improved=False):
star = "*" if primal_improved else ""
logger.info(
self.log_formatter.format(
"{}/{}".format(self.iteration, self.num_discrete_solns),
subproblem_type,
self.LB,
self.UB,
self.relative_gap(),
get_main_elapsed_time(self.timing),
star,
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh, I see. Sorry, I wasn't being very careful. It may be worth keeping the calculations for how many discrete solutions there are so that unsuspecting users get this info in the log. Do you agree?

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.94%. Comparing base (e099f64) to head (eb44fbf).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #4031   +/-   ##
=======================================
  Coverage   89.94%   89.94%           
=======================================
  Files         917      917           
  Lines      109261   109255    -6     
=======================================
- Hits        98276    98272    -4     
+ Misses      10985    10983    -2     
Flag Coverage Δ
builders 29.13% <7.69%> (+0.01%) ⬆️
default 86.14% <100.00%> (?)
expensive 35.10% <7.69%> (?)
linux 87.68% <100.00%> (-1.98%) ⬇️
linux_other 87.68% <100.00%> (+<0.01%) ⬆️
oldsolvers 27.59% <7.69%> (+<0.01%) ⬆️
osx 83.12% <100.00%> (+<0.01%) ⬆️
win 85.43% <100.00%> (+<0.01%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@alvaroborras

Copy link
Copy Markdown
Author

I think the cleanest approach is to keep iterating directly over _discrete_solution_iterator, while calculating num_discrete_solns separately only for the progress logger (which I would keep). The count would be computed arithmetically without materializing any solutions, and would use ceil(lb) and floor(ub) for integer-variable bounds. Does that look right to you?

@emma58

emma58 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@alvaroborras, yes, I like that plan!

@emma58 emma58 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks good, thank you! On comment below because I realized we don't correctly handle unbounded discrete variables. That's not this PR's fault, but is worth fixing before we merge if you're willing.

Comment on lines +138 to +140
self.num_discrete_solns *= 2 ** len(non_indicator_boolean_vars) * math.prod(
max(0, math.floor(v.ub) - math.ceil(v.lb) + 1) for v in discrete_vars
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, this isn't actually a bug introduced by this PR, but I just caught it: add_discrete_variable_list doesn't check for bounds, so this will error if v.lb or v.ub is None. I think we should check that case here because this is the first place we'll catch it. It will also be a problem in _discrete_solution_iterator.

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.

GDPopt enumerate materializes all discrete solutions before checking time_limit

3 participants