Skip to content

Memoize shared LinOps in Python canonicalization backends - #3423

Open
PTNobel wants to merge 5 commits into
masterfrom
ptn/python-backend-linop-memo
Open

PTNobel wants to merge 5 commits into
masterfrom
ptn/python-backend-linop-memo

Conversation

@PTNobel

@PTNobel PTNobel commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds a per-build_matrix memo to the Python canonicalization backend path (SCIPY and COO) keyed by Python LinOp object identity. When the same LinOp object appears multiple times, the backend now reuses the already-lowered TensorView instead of recursively lowering that LinOp tree again.

Cached TensorViews are cloned when stored and on cache hits, so mutating backend operations such as negation, row selection, and accumulation cannot alter the cached snapshot or another occurrence's view.

In order to control cache size, we first walk the full tree counting duplicates, and then only store objects that are needed later.

Issue link (if applicable):

Type of change

  • New feature (backwards compatible)
  • New feature (breaking API changes)
  • Bug fix
  • Other (Documentation, CI, ...)

Contribution checklist

  • Add our license to new files.
  • Check that your code adheres to our coding style.
  • Write unittests.
  • Run the unittests and check that they’re passing.
  • Run the benchmarks to make sure your change doesn’t introduce a regression.

Comment thread cvxpy/lin_ops/backends/base.py
Base automatically changed from ptn/dnlp2smooth-cse to master June 18, 2026 19:18
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Benchmarks that have stayed the same:

   before           after         ratio
 [20ce4a23]       [4caffc27]
   2.82±0.05s       2.92±0.02s     1.04  quantum_hilbert_matrix.QuantumHilbertMatrix.time_compile_problem
   1.79±0.01s       1.82±0.03s     1.01  simple_LP_benchmarks.SimpleScalarParametrizedLPBenchmark.time_compile_problem
   2.78±0.08s       2.82±0.03s     1.01  gini_portfolio.Cajas.time_compile_problem
      2.32±0s          2.34±0s     1.01  matrix_stuffing.SmallMatrixStuffing.time_compile_problem
   1.69±0.01s       1.70±0.02s     1.01  semidefinite_programming.SemidefiniteProgramming.time_compile_problem
    154±0.8ms        154±0.9ms     1.00  high_dim_convex_plasticity.ConvexPlasticity.time_compile_problem
      3.32±0s       3.34±0.01s     1.00  matrix_stuffing.ConeMatrixStuffingBench.time_compile_problem
      2.77±0s          2.77±0s     1.00  simple_QP_benchmarks.ParametrizedQPBenchmark.time_compile_problem
      1.46±0s       1.47±0.01s     1.00  tv_inpainting.TvInpainting.time_compile_problem
      4.32±0s       4.33±0.01s     1.00  optimal_advertising.OptimalAdvertising.time_compile_problem
      1.35±0s       1.34±0.01s     1.00  matrix_stuffing.ParamConeMatrixStuffing.time_compile_problem
   2.41±0.01s          2.40±0s     1.00  slow_pruning_1668_benchmark.SlowPruningBenchmark.time_compile_problem
      1.17±0s       1.16±0.01s     1.00  gini_portfolio.Murray.time_compile_problem
   10.1±0.01s          10.1±0s     0.99  simple_LP_benchmarks.SimpleLPBenchmark.time_compile_problem
   12.2±0.01s          12.2±0s     0.99  finance.CVaRBenchmark.time_compile_problem
      704±2ms          699±3ms     0.99  simple_LP_benchmarks.SimpleFullyParametrizedLPBenchmark.time_compile_problem
   3.54±0.05s       3.52±0.04s     0.99  matrix_stuffing.ParamSmallMatrixStuffing.time_compile_problem
   3.93±0.04s          3.89±0s     0.99  huber_regression.HuberRegression.time_compile_problem
   3.72±0.04s          3.67±0s     0.99  simple_QP_benchmarks.UnconstrainedQP.time_compile_problem
   4.15±0.07s       4.09±0.02s     0.99  simple_QP_benchmarks.SimpleQPBenchmark.time_compile_problem
   1.86±0.01s       1.83±0.03s     0.98  finance.FactorCovarianceModel.time_compile_problem
   2.06±0.05s       2.01±0.01s     0.98  simple_QP_benchmarks.LeastSquares.time_compile_problem
   1.35±0.05s       1.32±0.03s     0.97  gini_portfolio.Yitzhaki.time_compile_problem
    4.59±0.2s       4.44±0.02s     0.97  svm_l1_regularization.SVMWithL1Regularization.time_compile_problem
    21.5±0.5s          20.5±0s     0.96  sdp_segfault_1132_benchmark.SDPSegfault1132Benchmark.time_compile_problem

@PTNobel
PTNobel force-pushed the ptn/python-backend-linop-memo branch from 39e2c08 to 46af986 Compare June 18, 2026 19:47
@PTNobel

PTNobel commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

@claude merge in master

@claude

claude Bot commented Jul 1, 2026

Copy link
Copy Markdown

Claude finished @PTNobel's task in 2m 18s —— View job


Merging master into branch

  • Fetch latest master — blocked (see note below)
  • Check merge status via GitHub API
  • Merge master into ptn/python-backend-linop-memo
  • Push updated branch

Result

I wasn't able to perform an actual git merge here: this session's checkout is a shallow clone, and git fetch origin master (as well as gh api ...) is blocked by the sandbox with "this command requires approval" — there's no interactive user available to approve it in this automated run, so I have no way to pull the missing history objects.

However, I checked the PR's merge state directly via gh pr view 3423 --json mergeable,mergeStateStatus,reviewDecision:

{"mergeable":"MERGEABLE","mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED"}

mergeable: MERGEABLE means GitHub has already determined there are no conflicts between this branch and master — a merge commit would apply cleanly with no manual resolution needed. The BLOCKED status is coming from reviewDecision: REVIEW_REQUIRED (a pending review requirement), not from the branch being behind/conflicting with master.

So there isn't a conflict for me to resolve by hand, but I also can't push an actual "merge master in" commit from this environment since I can't fetch the new commits. If you'd like this done, the simplest options are:

  • Click "Update branch" on the PR in the GitHub UI (fast, no conflicts expected), or
  • Re-run me with fetch/network permissions enabled for git fetch/gh api in --allowedTools.

Comment on lines +507 to 524
try:
constraint_res = []
total_rows = sum(np.prod(lin_op.shape) for lin_op in lin_ops)
row_offset = 0
for lin_op in lin_ops:
lin_op_rows = np.prod(lin_op.shape)
empty_view = self.get_empty_view()
lin_op_tensor = self.process_constraint(lin_op, empty_view)
constraint_res.append(lin_op_tensor.get_tensor_representation(row_offset,
total_rows))
row_offset += lin_op_rows
tensor_res = self.concatenate_tensors(constraint_res)
finally:
del self._lin_op_counts
del self._lin_op_tensor_cache
self.id_to_col.pop(-1)
return tensor_res.flatten_tensor(self.param_size_plus_one, order=order)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am not a big fan of try catches generally.. and also don't really understand why we need one here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This isn't a try catch, it's a try finally. This makes sure that if we error we still drop the caches which aren't needed anymore before propagating the error.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

that's a great point, sorry for my misunderstanding. But now Claude is suggesting we could initialise these two new attributes in a new _init_ function for PythonCanonBackend, since that will only call build_matrix once. Then we don't really need to del these attributes anymore and maybe can also avoid the try/finally.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Hmm do we really guarantee that build_matrix will only be called once? If that's not documented I'm not comfortable just depending on existing behavior.

Also, I guess I preferred this approach so we can always drop the cache ASAP if Python wants the memory; doesn't the backend object stick around after build_matrix is called?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

One of my design goals is minimizing how much this caching changes our peak memory use hence the caution and eviction rules.

Comment on lines +525 to +534
def _count_reusable_lin_ops(self, lin_ops: list[LinOp]) -> Counter[int]:
raw_counts: Counter[int] = Counter()
for lin_op in lin_ops:
self._count_lin_op_tree(lin_op, raw_counts)

process_counts: Counter[int] = Counter()
seen_cacheable: set[int] = set()
for lin_op in lin_ops:
self._count_processed_lin_ops(lin_op, raw_counts, process_counts, seen_cacheable)
return process_counts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why do we need a count of these lin_ops? Can't we use a cache directly somehow?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We only cache linops that appear multiple times to control memory growth.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Also because we mutate, we need to copy the linop into the cache. That's expensive if we're never going to access it

Comment on lines +623 to +626
def _copy_tensor_data(self, data: Any) -> Any:
if isinstance(data, dict):
return {key: self._copy_tensor_data(value) for key, value in data.items()}
if data is None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

do we need to copy the tensor data? Isn't it the whole point to try to avoid copying data and reusing duplicate lin_op trees?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We mutate linops, we have to copy them or rearchitect the backends. The goal is to avoid recomputing linops, plus with the counts we never have to copy them more than the minimum times necessary.

Comment on lines +50 to +55
def spy_get_variable_tensor(self, shape, variable_id):
nonlocal calls
calls += 1
return original(self, shape, variable_id)

monkeypatch.setattr(backend_cls, "get_variable_tensor", spy_get_variable_tensor)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this test is really weirdly written, I don't quite like both the style, the naming conventions and its purpose.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It counts the number of times each linop is canonicalized via monkeypatch and then asserts they are all 1.

@PTNobel

PTNobel commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

@Transurgeon apologies, but what changes are requested? I saw design questions in your comments; if you have concerns about the design lmk and I'll revise.

@Transurgeon

Copy link
Copy Markdown
Collaborator

@Transurgeon apologies, but what changes are requested? I saw design questions in your comments; if you have concerns about the design lmk and I'll revise.

yes you are correct, they were more questions. I should have left them under a comment, apologies as well.
I'll take another look this weekend and let you know if I have more specific design changes.. thanks for your fast answers, they make things a bit more clearer now.

@Transurgeon Transurgeon left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I used Claude to do a more thorough review now and found a few things that could be improved.

  • firstly, we should have more and different tests which cover parameters and other expressions
  • secondly, there are some design improvements and suggestions. If possible, it would actually be nice to avoid caching leaf nodes since those should be de-duplicated already right?
  • thirdly, we should add some simple benchmarks results to verify that this memoization does the bring lots of performance benefits (at the very least locally, for simple but potentially large problems)

Honestly, this is going to be an awesome improvement to the backends, thanks for getting it started @PTNobel ! But I think there is still quite a bit of work left before I feel this is ready to merge.

Comment on lines +57 to +62
matrix = _problem_matrix([x, x], backend)

assert calls == 1
assert matrix.shape == (2 * 3 * (3 + 1), 1)
assert matrix.nnz == 2 * 3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I looked at the test more carefully and want to ask shouldn't leaf nodes be deduplicated already?

@PTNobel PTNobel Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't think they were before my last PR since I thought we created a new LinOp object for each time it appears. I'm pretty sure LinOps were trees not DAGs

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Now I'm worried I'm misunderstanding your question, what do you mean by deduplicated?

Comment on lines +77 to +81
matrix = _problem_matrix([x], backend)

assert calls == 0
assert matrix.shape == (3 * (3 + 1), 1)
assert matrix.nnz == 3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this test is really useful.. but happy to hear your thoughts on why we should keep it.

@PTNobel PTNobel Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I would prefer to keep tests that validate that we aren't caching unnecessarily. I'm open to other approaches, but I thought this worked since we copy info the cache

Comment on lines +98 to +103
matrix = _problem_matrix([neg_x, neg_x], backend)

assert calls == 2
assert matrix.shape == (2 * 3 * (3 + 1), 1)
assert matrix.nnz == 2 * 3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

it seems like this is also testing caching leaf nodes. since neg mutates the tensor object.
I think generally, we should have greater test coverage with more types of expressions (maybe some indexing as well, and nested expressions).
We should also add more tests that use parameters and ensure that they work as well.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I don't think that's true? The purpose of this test is to verify the leaf node wasn't cached

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm certainly open to more tests, but I don't understand what design you are pursuing with these suggestions.

Why indexing for example? Architecturally it's the same as neg a mutating LinOp iirc

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed on parameters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think in general these tests start making a bit more sense to me.
But I'd like to also have some where we test correctness rather than the internal calls. For example, we could derive some expected A matrices by hand and ensure that we get the correct result compared to building it with memoization.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I guess, don't we already have a ton of tests for canonicalization correctness? I don't see why we would add more tests. If memorization is breaking the output of the canon process it'll break the rest of our test suite.

It felt duplicative to repeat those in this file

Comment on lines +571 to +573
counts = getattr(self, "_lin_op_counts", None)
should_cache = counts is not None and counts[id(lin_op)] > 1
cache = getattr(self, "_lin_op_tensor_cache", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

we define these as attributes of the Backend Class on line 504-505 already so I don't know if its necessary to be defensive here (probably not).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Since we del them there, I thought it did since I wasn't too confident this function is never called, but I'm happy to just make them always present

Comment on lines +507 to 524
try:
constraint_res = []
total_rows = sum(np.prod(lin_op.shape) for lin_op in lin_ops)
row_offset = 0
for lin_op in lin_ops:
lin_op_rows = np.prod(lin_op.shape)
empty_view = self.get_empty_view()
lin_op_tensor = self.process_constraint(lin_op, empty_view)
constraint_res.append(lin_op_tensor.get_tensor_representation(row_offset,
total_rows))
row_offset += lin_op_rows
tensor_res = self.concatenate_tensors(constraint_res)
finally:
del self._lin_op_counts
del self._lin_op_tensor_cache
self.id_to_col.pop(-1)
return tensor_res.flatten_tensor(self.param_size_plus_one, order=order)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

that's a great point, sorry for my misunderstanding. But now Claude is suggesting we could initialise these two new attributes in a new _init_ function for PythonCanonBackend, since that will only call build_matrix once. Then we don't really need to del these attributes anymore and maybe can also avoid the try/finally.

finally:
del self._lin_op_counts
del self._lin_op_tensor_cache
self.id_to_col.pop(-1)

@Transurgeon Transurgeon Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this is also not related to the PR, we should remove it. (the self.id_to_col.pop(-1) line)

@PTNobel PTNobel Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That line was in the original code, I'm just indenting it. Why should it be deleted? I think it's needed for correctness. The new indent is part of the adding the try/finally.

Comment thread cvxpy/lin_ops/backends/base.py Outdated
@Transurgeon

Copy link
Copy Markdown
Collaborator

also I am worried about the ability to parallelise the backend with these new changes (I guess it is a trade-off to consider). For example cvxcore probably has some form of parallelisation (see #706).

@PTNobel

PTNobel commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

the ability to parallelise the backend with these new changes

I mean this only affects the pure python backends. We can't get any performance benefit from parallelization here on GIL'd python. I guess I'm not scared of sacrificing that.

(Also design that enables parallelization: on the tree walk to count, we can topologically sort the linops that need to be in the cache, populate the cache in parallel and then parallelize the evaluation of the rest of the linops with a read only cache)

This branch has not been deployed

No deployments
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.

2 participants