Skip to content

Commit e8a1bea

Browse files
authored
Merge pull request #3915 from Marl0nL/fix-appsi-memory-leak
Fix memory leak in appsi LegacySolverInterface wrapper
2 parents fcb8bdf + 97443e7 commit e8a1bea

3 files changed

Lines changed: 89 additions & 21 deletions

File tree

pyomo/contrib/appsi/base.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,10 +1601,8 @@ def solve(
16011601
symbol_map.bySymbol = dict(self.symbol_map.bySymbol)
16021602
symbol_map.aliases = dict(self.symbol_map.aliases)
16031603
symbol_map.default_labeler = self.symbol_map.default_labeler
1604-
model.solutions.add_symbol_map(symbol_map)
1605-
legacy_results._smap_id = id(symbol_map)
1604+
legacy_results._smap_id = None
16061605

1607-
delete_legacy_soln = True
16081606
if load_solutions:
16091607
if hasattr(model, 'dual') and model.dual.import_enabled():
16101608
for c, val in results.solution_loader.get_duals().items():
@@ -1616,7 +1614,6 @@ def solve(
16161614
for v, val in results.solution_loader.get_reduced_costs().items():
16171615
model.rc[v] = val
16181616
elif results.best_feasible_objective is not None:
1619-
delete_legacy_soln = False
16201617
for v, val in results.solution_loader.get_primals().items():
16211618
legacy_soln.variable[symbol_map.getSymbol(v)] = {'Value': val}
16221619
if hasattr(model, 'dual') and model.dual.import_enabled():
@@ -1631,9 +1628,9 @@ def solve(
16311628
for v, val in results.solution_loader.get_reduced_costs().items():
16321629
legacy_soln.variable['Rc'] = val
16331630

1634-
legacy_results.solution.insert(legacy_soln)
1635-
if delete_legacy_soln:
1636-
legacy_results.solution.delete(0)
1631+
legacy_results.solution.insert(legacy_soln)
1632+
legacy_results._smap = symbol_map
1633+
legacy_results._smap_id = id(symbol_map)
16371634

16381635
self.config = original_config
16391636
self.options = original_options
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# ____________________________________________________________________________________
2+
#
3+
# Pyomo: Python Optimization Modeling Objects
4+
# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC
5+
# Under the terms of Contract DE-NA0003525 with National Technology and Engineering
6+
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
7+
# software. This software is distributed under the 3-clause BSD License.
8+
# ____________________________________________________________________________________
9+
10+
import pyomo.environ as pyo
11+
import pyomo.common.unittest as unittest
12+
import gc
13+
from pyomo.contrib.appsi.cmodel import cmodel_available
14+
15+
from pyomo.common.dependencies import attempt_import
16+
17+
# Note: tracemalloc is not always available, e.g., under PyPy
18+
tracemalloc, tracemalloc_available = attempt_import('tracemalloc')
19+
20+
21+
class TestAppsiLegacyLeak(unittest.TestCase):
22+
@unittest.skipIf(not tracemalloc_available, "tracemalloc not available")
23+
@unittest.skipIf(not cmodel_available, "APPSI C-extension not available")
24+
def test_legacy_solver_wrapper_memory_leak(self):
25+
tracemalloc.start()
26+
# 1. Create a minimal structure
27+
model = pyo.ConcreteModel()
28+
model.I = pyo.RangeSet(100)
29+
model.x = pyo.Var(model.I)
30+
model.obj = pyo.Objective(expr=sum(model.x[i] for i in model.I))
31+
model.c = pyo.ConstraintList()
32+
for i in model.I:
33+
model.c.add(model.x[i] >= 0)
34+
35+
# 2. Instantiate Legacy Wrapper
36+
solver = pyo.SolverFactory('appsi_cbc')
37+
if not solver.available():
38+
raise unittest.SkipTest("appsi_cbc solver is not available")
39+
40+
solver.set_instance(model)
41+
42+
# Warm-up solve
43+
solver.solve(model)
44+
gc.collect()
45+
46+
# 3. Take initial memory snapshot
47+
48+
s1 = tracemalloc.take_snapshot()
49+
50+
# 4. Perform iterative solves
51+
iterations = 10
52+
for _ in range(iterations):
53+
solver.solve(model)
54+
55+
gc.collect()
56+
s2 = tracemalloc.take_snapshot()
57+
tracemalloc.stop()
58+
59+
# 5. Measure memory delta
60+
stats = s2.compare_to(s1, 'lineno')
61+
total_leak = sum(stat.size_diff for stat in stats)
62+
63+
initial_size = sum(stat.size for stat in s1.statistics('lineno'))
64+
final_size = sum(stat.size for stat in s2.statistics('lineno'))
65+
percentage_increase_per_solve = (total_leak / iterations) / initial_size * 100
66+
67+
# We allow a small tolerance for memory use growth, set here
68+
threshold_pct = 3
69+
print(f"Percentage increase per solve: {percentage_increase_per_solve}%")
70+
# Check if the leak is substantial
71+
self.assertLess(
72+
percentage_increase_per_solve,
73+
threshold_pct,
74+
f"More than {threshold_pct}% memory leak detected across iterations",
75+
)
76+
77+
78+
if __name__ == "__main__":
79+
unittest.main()

pyomo/contrib/solver/common/base.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -559,16 +559,8 @@ def _solution_handler(
559559
"""Method to handle the preferred action for the solution"""
560560
symbol_map = legacy_soln.symbol_map = SymbolMap()
561561
symbol_map.default_labeler = NumericLabeler('x')
562-
if not hasattr(model, 'solutions'):
563-
# This logic gets around Issue #2130 in which
564-
# solutions is not an attribute on Blocks
565-
from pyomo.core.base.PyomoModel import ModelSolutions
566-
567-
setattr(model, 'solutions', ModelSolutions(model))
568-
model.solutions.add_symbol_map(symbol_map)
569-
legacy_results._smap = symbol_map
570-
legacy_results._smap_id = id(symbol_map)
571-
delete_legacy_soln = True
562+
legacy_results._smap_id = None
563+
572564
if load_solutions:
573565
if hasattr(model, 'dual') and model.dual.import_enabled():
574566
for con, val in results.solution_loader.get_duals().items():
@@ -577,7 +569,6 @@ def _solution_handler(
577569
for var, val in results.solution_loader.get_reduced_costs().items():
578570
model.rc[var] = val
579571
elif results.incumbent_objective is not None:
580-
delete_legacy_soln = False
581572
for var, val in results.solution_loader.get_primals().items():
582573
legacy_soln.variable[symbol_map.getSymbol(var)] = {'Value': val}
583574
if hasattr(model, 'dual') and model.dual.import_enabled():
@@ -587,15 +578,16 @@ def _solution_handler(
587578
for var, val in results.solution_loader.get_reduced_costs().items():
588579
legacy_soln.variable['Rc'] = val
589580

590-
legacy_results.solution.insert(legacy_soln)
581+
legacy_results.solution.insert(legacy_soln)
582+
legacy_results._smap_id = id(symbol_map)
583+
legacy_results._smap = symbol_map
584+
591585
# Timing info was not originally on the legacy results, but we
592586
# want to make it accessible to folks who are utilizing the
593587
# backwards compatible version. Note that embedding the
594588
# ConfigDict broke pickling the legacy_results, so we will only
595589
# return raw nested dicts
596590
legacy_results.timing_info = results.timing_info.value()
597-
if delete_legacy_soln:
598-
legacy_results.solution.delete(0)
599591
return legacy_results
600592

601593
def solve(

0 commit comments

Comments
 (0)