Python api performance improvement - #1615
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe pull request adds automatic staleness tracking for model edits, typed CSR caches, selective ChangesCache-aware linear programming problem
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
script_perf_eval.py (1)
210-218: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid 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 computex @ 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
📒 Files selected for processing (2)
python/cuopt/cuopt/linear_programming/problem.pyscript_perf_eval.py
|
/ok to test 6834882 |
|
/ok to test a2ac39a |
|
@coderabiitai review |
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 |
There was a problem hiding this comment.
Consider refactoring so that the constraint index is always the same as the csr row index?
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
Why np.fromiter above and np.asarray here?
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Do we need to generate row names if they're not provided?
There was a problem hiding this comment.
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
| # 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
/ok to test 8a01887 |
|
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. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (4)
python/cuopt/cuopt/linear_programming/problem.py (4)
144-155: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winMeasure the
__setattr__overhead on the solution-copy path.Every write to a
Variableattribute now pays an extra Python frame plus a set lookup.populate_solutionwritesValueandReducedCostfor each variable, andreset_solved_valueswrites 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 valueConsider one helper for structural cache invalidation.
addVariable(Lines 1885-1892),addConstraint(Lines 1923-1930), andreset_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_stalekey is added in one place. Keepobjective_qmatrixuntouched here, since onlyreset_solved_valuesclears 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 winDocument the accepted
coeffstypes and the raised errors.
updateConstraintnow accepts a dict forcoeffsand raisesValueErrorin four cases: a non-Constraintargument, a constraint that does not belong to the Problem, a quadratic constraint, and (with the check above) a foreign variable. The docstring still describescoeffsas 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 winRemove the unused index-to-variable cache.
No repository code calls
_index_to_var()or passesindex_to_var=tocompute_slack. Remove_index_to_varand_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
📒 Files selected for processing (1)
python/cuopt/cuopt/linear_programming/problem.py
| 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 |
There was a problem hiding this comment.
🗄️ 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.
|
/ok to test 6d4d4ed |
Description
The 100ms shave off helps in consecutive solves of portfolio problems which solve in 300-500ms.
Issue
Checklist