Skip to content

Python api performance improvement - #1615

Open
Iroy30 wants to merge 12 commits into
NVIDIA:mainfrom
Iroy30:python_api_performance
Open

Python api performance improvement#1615
Iroy30 wants to merge 12 commits into
NVIDIA:mainfrom
Iroy30:python_api_performance

Conversation

@Iroy30

@Iroy30 Iroy30 commented Jul 23, 2026

Copy link
Copy Markdown
Member

Description

  • improved populate_solution slack computation. Drops ~68 ms to ~6 ms
  • Selective datamodel update depending on the data updated (as long as structure remains the same) instead of rebuild CSR and model each time. Drops ~40 ms to ~2 ms
  • fixed a pre-existing attribute update bug

The 100ms shave off helps in consecutive solves of portfolio problems which solve in 300-500ms.

Issue

Checklist

  • I am familiar with the Contributing Guidelines.
  • Testing
    • New or existing tests cover these changes
    • Added tests
    • Created an issue to follow-up
    • NA
  • Documentation
    • The documentation is up to date with these changes
    • Added new documentation
    • NA

Iroy30 and others added 2 commits July 20, 2026 03:46
Reduce model refresh and solution-population overhead independently of solver session persistence.

Signed-off-by: Ishika Roy <iroy@ipp1-3302.aselab.nvidia.com>
Reuse cached model structures for value-only changes and avoid redundant solution invalidation across batched updates.
@Iroy30
Iroy30 requested a review from a team as a code owner July 23, 2026 23:35
@Iroy30
Iroy30 requested a review from tmckayus July 23, 2026 23:35
@copy-pr-bot

copy-pr-bot Bot commented Jul 23, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f10d7bea-a1c4-45eb-b97a-da0944716599

📥 Commits

Reviewing files that changed from the base of the PR and between 98cda41 and 6d4d4ed.

📒 Files selected for processing (1)
  • python/cuopt/cuopt/linear_programming/problem.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • python/cuopt/cuopt/linear_programming/problem.py

📝 Walkthrough

Walkthrough

The pull request adds automatic staleness tracking for model edits, typed CSR caches, selective DataModel refreshes, stronger constraint updates, optimized slack calculation, and isolated relaxation copies.

Changes

Cache-aware linear programming problem

Layer / File(s) Summary
CSR cache and staleness foundation
python/cuopt/cuopt/linear_programming/problem.py
Problem now maintains typed CSR data, index mappings, constraint metadata, and category-specific stale flags.
Mutation invalidation and value updates
python/cuopt/cuopt/linear_programming/problem.py
Variable, constraint, and objective edits mark affected state stale. Constraint updates validate inputs and patch CSR values when sparsity is unchanged.
Model refresh and solution paths
python/cuopt/cuopt/linear_programming/problem.py
MPS writing, CSR retrieval, and solving choose between structural rebuilds and selective refreshes. Slack calculation uses CSR matrix-vector multiplication.
Relaxation copy and solved-state handling
python/cuopt/cuopt/linear_programming/problem.py
relax preserves the original model and solution while resetting only the relaxed clone. reset_solved_values supports independent invalidation controls.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: tmckayus

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: improving Python API performance.
Description check ✅ Passed The description directly explains the performance improvements, selective updates, and attribute bug fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
script_perf_eval.py (1)

210-218: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid materializing the dense diagonal matrix.

np.diag(info["D_diag"]) allocates an n×n dense array (n≈5000 ⇒ ~200 MB) on every objective evaluation just to compute x @ D @ x. Use the elementwise form.

♻️ Elementwise diagonal quadratic term
     y = info["F"].T @ x_np
     z = np.abs(x_np - info["x0"])
-    d_matrix = np.diag(info["D_diag"])
     return (
         -info["mu"] @ x_np
         + info["gamma"]
-        * (x_np @ d_matrix @ x_np + y @ info["Omega"] @ y)
+        * (x_np @ (info["D_diag"] * x_np) + y @ info["Omega"] @ y)
         + info["tc_rate"] * np.sum(z)
     )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@script_perf_eval.py` around lines 210 - 218, Update the objective calculation
around the `d_matrix` expression to avoid constructing
`np.diag(info["D_diag"])`; compute the diagonal quadratic term elementwise as
the sum of `info["D_diag"]` multiplied by `x_np` squared, while preserving the
existing objective value and remaining terms.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/cuopt/cuopt/linear_programming/problem.py`:
- Around line 2450-2457: Update Problem.solve to accept the session argument
used by script_perf_eval.py and pass it through to solver.Solve, preserving
existing settings behavior; alternatively, remove the session keyword from that
caller so the signatures remain aligned.

In `@script_perf_eval.py`:
- Line 778: Update the objective-record comparison loop over
baseline["objective_records"] and session["objective_records"] to use strict zip
semantics, ensuring differing record counts raise an error instead of silently
truncating. Preserve the existing per-record comparison logic.
- Around line 110-130: Update _capture_solver_output to drain the stderr pipe
concurrently while the yielded solve runs, using a reader thread or equivalent
that continuously consumes and stores output. Ensure cleanup restores fd 2,
waits for the reader to finish, closes descriptors, and preserves captured
output forwarding to sys.stderr.
- Around line 385-386: Initialize prob._session before the session-handling
logic in the relevant baseline/cold-session flow, ensuring it exists before
session_after_cold or any other read. Preserve the existing use_session behavior
that clears the session when enabled, and use a safe default of None for
uninitialized sessions.
- Around line 481-482: Update the Problem.solve invocation in the
_capture_solver_output block to pass only settings, removing the conditional
session keyword argument. Preserve the surrounding solver-output capture and
solution assignment behavior.

---

Nitpick comments:
In `@script_perf_eval.py`:
- Around line 210-218: Update the objective calculation around the `d_matrix`
expression to avoid constructing `np.diag(info["D_diag"])`; compute the diagonal
quadratic term elementwise as the sum of `info["D_diag"]` multiplied by `x_np`
squared, while preserving the existing objective value and remaining terms.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: c3b79d8a-3433-4ba3-bd5c-d4c71b4790c7

📥 Commits

Reviewing files that changed from the base of the PR and between 9bba74f and 6834882.

📒 Files selected for processing (2)
  • python/cuopt/cuopt/linear_programming/problem.py
  • script_perf_eval.py

Comment thread script_perf_eval.py Outdated
Comment thread script_perf_eval.py Outdated
Comment thread script_perf_eval.py Outdated
Comment thread script_perf_eval.py Outdated
@tmckayus tmckayus added non-breaking Introduces a non-breaking change improvement Improves an existing functionality labels Jul 24, 2026
@tmckayus

Copy link
Copy Markdown
Contributor

/ok to test 6834882

@Iroy30

Iroy30 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

/ok to test a2ac39a

@Iroy30

Iroy30 commented Jul 24, 2026

Copy link
Copy Markdown
Member Author

@coderabiitai review

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

CI Test Summary

✅ All 22 test job(s) passed. (1 skipped)

self.var_type = None
self._index_to_var_cache = None
self._constraint_csr_scipy = None
self._constraint_index_to_csr_row = None

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.

Consider refactoring so that the constraint index is always the same as the csr row index?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

CSR only stores the linear constraint matrix A. Constraint indices are assigned in add order across all constraints (linear and quadratic) for inspection/accessors. Quadratic constraints are excluded from CSR, so constr.index is not always the CSR row index; _constraint_index_to_csr_row maps between them.

count=m,
)
self.row_sense = np.asarray(
[constr.Sense for constr in linear_constrs], dtype="S1"

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.

Why np.fromiter above and np.asarray here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

was updated as a part of python overhead reduction strategy for consecutive solves. It saves ~10ms for an engine solve of 300ms for 275k nnz problem. Can handle numeric values.
I agree that there needs to be consistency but these small wins can add up as problem grows.

[constr.Sense for constr in linear_constrs], dtype="S1"
)
self.row_names = [
constr.ConstraintName or "R" + str(constr.index)

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.

Do we need to generate row names if they're not provided?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was already existing code, the change just removed a if conditional-check hence the indentation showing up as new lines of code.
As to why we name it - it is to pass the user-given names to MPS ensuring that if a row is unnamed in python it is generated so that set_row_names can be called without empty strings

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated

# otherwise leave Slack as NaN (same outcome as unset Values).
slacks = None
if len(primal_sol) == len(self.vars):
A = self._constraint_csr_scipy_matrix()

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 like the only place in the code where _constraint_csr_scipy_matrix is used. Are we keeping a duplicate copy of the matrix in memory just to compute slack values? We should be sensitive to changes in peak memory usage. An extra copy of the constraint matrix isn't cheap.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's a good point, yes it is mainly for slack and it cuts down the computation quite a bit. We could delete it after compute instead of storing and maintaining.

@Iroy30

Iroy30 commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

/ok to test 8a01887

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
python/cuopt/cuopt/linear_programming/problem.py (4)

144-155: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Measure the __setattr__ overhead on the solution-copy path.

Every write to a Variable attribute now pays an extra Python frame plus a set lookup. populate_solution writes Value and ReducedCost for each variable, and reset_solved_values writes two more. For large models this adds cost to the same hot path this PR optimizes.

If the benchmark shows measurable overhead, bypass the interceptor at the known-safe hot sites instead of widening _OUTPUT_ATTRIBUTES:

# in populate_solution, per variable
vd = var.__dict__
vd["Value"] = primal_sol[var.index]
if not IsMIP and reduced_cost is not None and len(reduced_cost) > 0:
    vd["ReducedCost"] = reduced_cost[var.index]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cuopt/cuopt/linear_programming/problem.py` around lines 144 - 155,
Benchmark the solution-copy path involving __setattr__, especially
populate_solution and reset_solved_values, to measure the added interceptor and
lookup overhead for Variable writes. If measurable, bypass __setattr__ at these
known-safe hot sites by assigning Value and ReducedCost through each variable’s
__dict__, while preserving the existing conditions and behavior; do not expand
_OUTPUT_ATTRIBUTES.

1885-1892: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider one helper for structural cache invalidation.

addVariable (Lines 1885-1892), addConstraint (Lines 1923-1930), and reset_solved_values (Lines 1846-1853) repeat the same invalidation block. The stale-key list must stay in sync across all three. Extract a private helper so a future _stale key is added in one place. Keep objective_qmatrix untouched here, since only reset_solved_values clears it.

♻️ Suggested helper
def _invalidate_structure_caches(self):
    self.model = None
    self.constraint_csr_matrix = None
    self._invalidate_index_to_var_cache()
    self._mark_stale("structure", "variable", "objective", "rhs", "A_values")
         if self.solved:
             self.reset_solved_values()
-        self.constraint_csr_matrix = None
-        self.model = None
-        self._invalidate_index_to_var_cache()
-        self._mark_stale(
-            "structure", "variable", "objective", "rhs", "A_values"
-        )
+        self._invalidate_structure_caches()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cuopt/cuopt/linear_programming/problem.py` around lines 1885 - 1892,
Extract the repeated structural cache invalidation logic from addVariable,
addConstraint, and reset_solved_values into a private
_invalidate_structure_caches helper, including model and constraint_csr_matrix
clearing, index-cache invalidation, and the shared stale keys. Replace each
duplicated block with the helper call, while leaving reset_solved_values’
objective_qmatrix clearing separate and unchanged.

1968-1983: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the accepted coeffs types and the raised errors.

updateConstraint now accepts a dict for coeffs and raises ValueError in four cases: a non-Constraint argument, a constraint that does not belong to the Problem, a quadratic constraint, and (with the check above) a foreign variable. The docstring still describes coeffs as a list of tuples and has no Raises section.

📝 Suggested docstring content
-        coeffs : List[Tuple[:py:class:`Variable`, coefficient]]
-            List of Tuples containing variable and corresponding coefficient.
-            Optional.
+        coeffs : List[Tuple[:py:class:`Variable`, coefficient]] or Dict[:py:class:`Variable`, coefficient]
+            Variable/coefficient pairs to set on the constraint. Existing
+            coefficients are replaced, not accumulated. Optional.
         rhs : int|float
             New RHS value for the constraint.
+
+        Raises
+        ------
+        ValueError
+            If ``constr`` is not a :py:class:`Constraint`, does not belong to
+            this Problem, or is a quadratic constraint.

As per path instructions: "Docstring CONTENT on new public APIs — params, returns, raises — even when pydocstyle format rules pass".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cuopt/cuopt/linear_programming/problem.py` around lines 1968 - 1983,
Update the updateConstraint docstring to document that coeffs accepts either a
list of variable-coefficient tuples or a dict, and add a Raises section covering
ValueError for a non-Constraint, a constraint not belonging to this Problem, a
quadratic constraint, and a foreign variable. Keep the documentation aligned
with the validation performed by updateConstraint.

Source: Path instructions


1726-1736: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the unused index-to-variable cache.

No repository code calls _index_to_var() or passes index_to_var= to compute_slack. Remove _index_to_var and _index_to_var_cache. Keep _invalidate_index_to_var_cache() only for _constraint_index_to_csr_row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cuopt/cuopt/linear_programming/problem.py` around lines 1726 - 1736,
Remove the unused _index_to_var method and _index_to_var_cache state from the
problem implementation, along with any initialization or references to them.
Update _invalidate_index_to_var_cache to invalidate only
_constraint_index_to_csr_row, and remove any obsolete index_to_var-related
arguments or plumbing if present.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@python/cuopt/cuopt/linear_programming/problem.py`:
- Around line 1985-1990: Validate every variable in coeffs belongs to the
current Problem before using var.index or updating constr.vindex_coeff_dict.
Update the surrounding constraint-coefficient handling to reject foreign or
out-of-range Variable instances with a clear Python error, while preserving the
existing coefficient update and has_new_nonzero behavior for valid variables.
- Around line 2506-2513: Update the data-model synchronization path around
_to_data_model, _refresh_data_model_values, and the read/readMPS loaders so
QPS-loaded quadratic objectives and constraints remain intact when solve or
writeMPS is called. Preserve the loaded DataModel or fully initialize the
quadratic cache state before any rebuild, and add regression coverage for both
read–solve and read–write QPS workflows.

---

Nitpick comments:
In `@python/cuopt/cuopt/linear_programming/problem.py`:
- Around line 144-155: Benchmark the solution-copy path involving __setattr__,
especially populate_solution and reset_solved_values, to measure the added
interceptor and lookup overhead for Variable writes. If measurable, bypass
__setattr__ at these known-safe hot sites by assigning Value and ReducedCost
through each variable’s __dict__, while preserving the existing conditions and
behavior; do not expand _OUTPUT_ATTRIBUTES.
- Around line 1885-1892: Extract the repeated structural cache invalidation
logic from addVariable, addConstraint, and reset_solved_values into a private
_invalidate_structure_caches helper, including model and constraint_csr_matrix
clearing, index-cache invalidation, and the shared stale keys. Replace each
duplicated block with the helper call, while leaving reset_solved_values’
objective_qmatrix clearing separate and unchanged.
- Around line 1968-1983: Update the updateConstraint docstring to document that
coeffs accepts either a list of variable-coefficient tuples or a dict, and add a
Raises section covering ValueError for a non-Constraint, a constraint not
belonging to this Problem, a quadratic constraint, and a foreign variable. Keep
the documentation aligned with the validation performed by updateConstraint.
- Around line 1726-1736: Remove the unused _index_to_var method and
_index_to_var_cache state from the problem implementation, along with any
initialization or references to them. Update _invalidate_index_to_var_cache to
invalidate only _constraint_index_to_csr_row, and remove any obsolete
index_to_var-related arguments or plumbing if present.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: bf830d11-ba20-4547-9996-ffe99c7aa32a

📥 Commits

Reviewing files that changed from the base of the PR and between 2f035ba and 98cda41.

📒 Files selected for processing (1)
  • python/cuopt/cuopt/linear_programming/problem.py

Comment on lines +1985 to +1990
has_new_nonzero = False
if coeffs:
for var, coeff in coeffs:
if var.index not in constr.vindex_coeff_dict:
has_new_nonzero = True
constr.vindex_coeff_dict[var.index] = coeff

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate that each coeffs variable belongs to this Problem.

The new checks validate constr but not the variables. If a caller passes a Variable from another Problem, var.index can exceed len(self.vars) - 1. The value lands in vindex_coeff_dict, and the next _to_data_model() forwards that column index to set_csr_constraint_matrix, so an out-of-range column reaches the native layer instead of raising a clear Python error.

🛡️ Proposed validation
         has_new_nonzero = False
         if coeffs:
+            n = len(self.vars)
             for var, coeff in coeffs:
+                if not isinstance(var, Variable) or not (0 <= var.index < n):
+                    raise ValueError(
+                        "coeffs must reference variables of this Problem"
+                    )
                 if var.index not in constr.vindex_coeff_dict:
                     has_new_nonzero = True
                 constr.vindex_coeff_dict[var.index] = coeff
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cuopt/cuopt/linear_programming/problem.py` around lines 1985 - 1990,
Validate every variable in coeffs belongs to the current Problem before using
var.index or updating constr.vindex_coeff_dict. Update the surrounding
constraint-coefficient handling to reject foreign or out-of-range Variable
instances with a clear Python error, while preserving the existing coefficient
update and has_new_nonzero behavior for valid variables.

Comment thread python/cuopt/cuopt/linear_programming/problem.py
@Iroy30

Iroy30 commented Aug 4, 2026

Copy link
Copy Markdown
Member Author

/ok to test 6d4d4ed

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

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants