Conversation
|
Benchmarks that have stayed the same: |
39e2c08 to
46af986
Compare
|
@claude merge in master |
|
Claude finished @PTNobel's task in 2m 18s —— View job Merging master into branch
ResultI wasn't able to perform an actual However, I checked the PR's merge state directly via {"mergeable":"MERGEABLE","mergeStateStatus":"BLOCKED","reviewDecision":"REVIEW_REQUIRED"}
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:
|
| 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) | ||
|
|
There was a problem hiding this comment.
I am not a big fan of try catches generally.. and also don't really understand why we need one here.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
One of my design goals is minimizing how much this caching changes our peak memory use hence the caution and eviction rules.
| 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 |
There was a problem hiding this comment.
why do we need a count of these lin_ops? Can't we use a cache directly somehow?
There was a problem hiding this comment.
We only cache linops that appear multiple times to control memory growth.
There was a problem hiding this comment.
Also because we mutate, we need to copy the linop into the cache. That's expensive if we're never going to access it
| 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: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
this test is really weirdly written, I don't quite like both the style, the naming conventions and its purpose.
There was a problem hiding this comment.
It counts the number of times each linop is canonicalized via monkeypatch and then asserts they are all 1.
|
@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. |
Transurgeon
left a comment
There was a problem hiding this comment.
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.
| matrix = _problem_matrix([x, x], backend) | ||
|
|
||
| assert calls == 1 | ||
| assert matrix.shape == (2 * 3 * (3 + 1), 1) | ||
| assert matrix.nnz == 2 * 3 | ||
|
|
There was a problem hiding this comment.
I looked at the test more carefully and want to ask shouldn't leaf nodes be deduplicated already?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Now I'm worried I'm misunderstanding your question, what do you mean by deduplicated?
| matrix = _problem_matrix([x], backend) | ||
|
|
||
| assert calls == 0 | ||
| assert matrix.shape == (3 * (3 + 1), 1) | ||
| assert matrix.nnz == 3 |
There was a problem hiding this comment.
I don't think this test is really useful.. but happy to hear your thoughts on why we should keep it.
There was a problem hiding this comment.
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
| matrix = _problem_matrix([neg_x, neg_x], backend) | ||
|
|
||
| assert calls == 2 | ||
| assert matrix.shape == (2 * 3 * (3 + 1), 1) | ||
| assert matrix.nnz == 2 * 3 | ||
|
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I don't think that's true? The purpose of this test is to verify the leaf node wasn't cached
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Agreed on parameters
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| 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) |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
| 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) | ||
|
|
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
this is also not related to the PR, we should remove it. (the self.id_to_col.pop(-1) line)
There was a problem hiding this comment.
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.
|
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). |
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) |
Description
Adds a per-
build_matrixmemo to the Python canonicalization backend path (SCIPYandCOO) 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
Contribution checklist