Repository: cvxpy/cvxpy
Pull request #2724: Adds new broadcast_to atom for nd broadcasting
Author: login: Transurgeon
Labels: name: PR backport needed
Created: 2025-02-21T03:19:55Z | Merged: 2025-03-09T22:08:32Z

## Description
Please include a short summary of the change.
This PR introduces the broadcast_to atom in hopes of fixing issues with broadcasting in higher dimensions. 
The broadcast_to atom is used internally inside of the expression class in its ``broadcast`` method.
This method is called in many places, including binary ops, basic ops and others.

The implementation of the atom follows other similar shape manipulation atoms such as vstack and concatenate. The idea is to form a set of indices which are reshaped to the expression's shape, then using numpy's API we are able to mimic the behavior of ``np.broadcast_to`` on cvxpy's tensor views in the backend. 

### CPP backend compatibility
In the #2728 discussion, I brought up some issues with the difference in behavior for the backends. Those issues were resolved as follows: 
- only call ``cp.broadcast_to`` when one of the operands has dim > 2, which ensures that the SCIPY backend is used.
- full broadcasting is supported in those cases, even the edge cases mentioned in the discussion above.
- in hopes of keeping the CPP backend behavior the same, both the SCIPY and CPP backends don't do full broadcasting when both operands are <= 2. However, this only omits some edge cases, as most practical scenarios are covered by existing code. 

### Improved error messages
Recent changes to the codebase (notably adding the cp.concatenate atom) showed that we are able to instantiate empty numpy arrays (using ``dtype=np.empty([])``) and then perform analogous operations on those arrays to get the error handling from numpy for free. This technique has been reused here to improve on the existing multiply atom error handling. 

Issue link (if applicable): #2739 

## Type of change
- [x] New feature (backwards compatible)
- [ ] New feature (breaking API changes)
- [ ] Bug fix
- [ ] Other (Documentation, CI, ...)

## [Contribution checklist](https://www.cvxpy.org/contributing/index.html#contribution-checklist)
- [x] Add our license to new files.
- [x] Check that your code adheres to our coding style.
- [x] Write unittests.
- [x] Run the unittests and check that they’re passing.
- [x] Run the benchmarks to make sure your change doesn’t introduce a regression.


Diff:
diff --git a/cvxpy/atoms/__init__.py b/cvxpy/atoms/__init__.py
index 6b6c8f2bca..b7d69eb0df 100644
--- a/cvxpy/atoms/__init__.py
+++ b/cvxpy/atoms/__init__.py
@@ -17,6 +17,7 @@
 from cvxpy.atoms.affine.binary_operators import (matmul, multiply,
                                                  vdot, scalar_product, outer,)
 from cvxpy.atoms.affine.bmat import bmat
+from cvxpy.atoms.affine.broadcast_to import broadcast_to
 from cvxpy.atoms.affine.conj import conj
 from cvxpy.atoms.affine.conv import conv, convolve
 from cvxpy.atoms.affine.cumsum import cumsum
diff --git a/cvxpy/atoms/affine/binary_operators.py b/cvxpy/atoms/affine/binary_operators.py
index a8434a69f8..edbed6f7f2 100644
--- a/cvxpy/atoms/affine/binary_operators.py
+++ b/cvxpy/atoms/affine/binary_operators.py
@@ -232,21 +232,18 @@ def graph_implementation(
 
 
 class multiply(MulExpression):
-    """ Multiplies two expressions elementwise.
-    """
+    """Multiplies two expressions elementwise."""
 
     def __init__(self, lh_expr, rh_expr) -> None:
         lh_expr, rh_expr = self.broadcast(lh_expr, rh_expr)
         super(multiply, self).__init__(lh_expr, rh_expr)
 
     def is_atom_log_log_convex(self) -> bool:
-        """Is the atom log-log convex?
-        """
+        """Is the atom log-log convex?"""
         return True
 
     def is_atom_log_log_concave(self) -> bool:
-        """Is the atom log-log concave?
-        """
+        """Is the atom log-log concave?"""
         return True
 
     def is_atom_quasiconvex(self) -> bool:
@@ -262,8 +259,7 @@ def is_atom_quasiconcave(self) -> bool:
             arg.is_nonpos() for arg in self.args)
 
     def numeric(self, values):
-        """Multiplies the values elementwise.
-        """
+        """Multiplies the values elementwise."""
         if sp.issparse(values[0]):
             return values[0].multiply(values[1])
         elif sp.issparse(values[1]):
@@ -271,10 +267,16 @@ def numeric(self, values):
         else:
             return np.multiply(values[0], values[1])
 
+    def validate_arguments(self):
+        """Validate that the arguments are broadcastable."""
+        np.broadcast(
+            np.empty(self.args[0].shape, dtype=np.dtype([])),
+            np.empty(self.args[1].shape, dtype=np.dtype([]))
+        )
+
     def shape_from_args(self) -> Tuple[int, ...]:
-        """The sum of the argument dimensions - 1.
-        """
-        return u.shape.sum_shapes([arg.shape for arg in self.args])
+        """Call np.broadcast on multiply arguments."""
+        return np.broadcast_shapes(self.args[0].shape, self.args[1].shape)
 
     def is_psd(self) -> bool:
         """Is the expression a positive semidefinite matrix?
diff --git a/cvxpy/atoms/affine/broadcast_to.py b/cvxpy/atoms/affine/broadcast_to.py
new file mode 100644
index 0000000000..bcf34fe0ed
--- /dev/null
+++ b/cvxpy/atoms/affine/broadcast_to.py
@@ -0,0 +1,81 @@
+"""
+Copyright, the CVXPY authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from typing import List, Optional, Tuple
+
+import numpy as np
+
+import cvxpy.lin_ops.lin_op as lo
+import cvxpy.lin_ops.lin_utils as lu
+from cvxpy.atoms.affine.affine_atom import AffAtom
+from cvxpy.constraints.constraint import Constraint
+
+
+class broadcast_to(AffAtom):
+    """Broadcast the expression given a shape input"""
+
+    def __init__(self, expr, shape) -> None:
+        self.broadcast_shape = shape
+        self._shape = expr.shape
+        super(broadcast_to, self).__init__(expr)
+
+    def _supports_cpp(self) -> bool:
+        return False
+
+    def is_atom_log_log_convex(self) -> bool:
+        return True
+
+    def is_atom_log_log_concave(self) -> bool:
+        return True
+
+    def numeric(self, values):
+        return np.broadcast_to(values[0], shape=self.broadcast_shape)
+
+    def get_data(self) -> List[Optional[int]]:
+        return [self.broadcast_shape]
+
+    def validate_arguments(self) -> None:
+        np.broadcast_to(
+            np.empty(self.shape, dtype=np.dtype([])),
+            shape=self.broadcast_shape
+        )
+
+    def shape_from_args(self) -> Tuple[int, ...]:
+        return self.broadcast_shape
+
+    def graph_implementation(
+        self,
+        arg_objs,
+        shape: Tuple[int, ...],
+        data=None,
+    ) -> Tuple[lo.LinOp, List[Constraint]]:
+        """Broadcast an expression to a given shape.
+
+        Parameters
+        ----------
+        arg_objs : list
+            LinOp for each argument.
+        shape : tuple
+            The shape of the resulting expression.
+        data :
+            Additional data required by the atom. In this case data wraps axis
+
+        Returns
+        -------
+        tuple
+            (LinOp for the objective, list of constraints)
+        """
+        return (lu.broadcast_to(arg_objs, shape), [])
diff --git a/cvxpy/atoms/affine/promote.py b/cvxpy/atoms/affine/promote.py
index 60b8598ee4..a908eb5ce1 100644
--- a/cvxpy/atoms/affine/promote.py
+++ b/cvxpy/atoms/affine/promote.py
@@ -50,7 +50,7 @@ def promote(expr: Expression, shape: Tuple[int, ...]):
 
 
 class Promote(AffAtom):
-    """ Promote a scalar expression to a vector/matrix.
+    """Promote a scalar expression to a vector/matrix.
 
     Attributes
     ----------
diff --git a/cvxpy/expressions/expression.py b/cvxpy/expressions/expression.py
index c97eb71ae7..7e62da51ed 100644
--- a/cvxpy/expressions/expression.py
+++ b/cvxpy/expressions/expression.py
@@ -17,10 +17,11 @@
 import abc
 import warnings
 from functools import wraps
-from typing import List, Literal, Tuple
+from typing import List, Literal, Optional, Tuple
 
 import numpy as np
 
+import cvxpy as cp
 import cvxpy.settings as s
 import cvxpy.utilities as u
 import cvxpy.utilities.key_utils as ku
@@ -124,12 +125,12 @@ class Expression(u.Canonical):
 
     @property
     @abc.abstractmethod
-    def value(self):
-        """NumPy.ndarray or None : The numeric value of the expression.
+    def value(self) -> Optional[np.ndarray]:
+        """Returns: The numeric value of the expression.
         """
         raise NotImplementedError()
 
-    def _value_impl(self):
+    def _value_impl(self) -> Optional[np.ndarray]:
         """Implementation of .value.
         """
         return self.value
@@ -500,18 +501,15 @@ def flatten(self, order: Literal["F", "C", None] = None):
         return cvxtypes.vec()(self, order)
 
     def is_scalar(self) -> bool:
-        """Is the expression a scalar?
-        """
+        """Is the expression a scalar?"""
         return all(d == 1 for d in self.shape)
 
     def is_vector(self) -> bool:
-        """Is the expression a column or row vector?
-        """
+        """Is the expression a column or row vector?"""
         return self.ndim <= 1 or (self.ndim == 2 and min(self.shape) == 1)
 
     def is_matrix(self) -> bool:
-        """Is the expression a matrix?
-        """
+        """Is the expression a matrix?"""
         return self.ndim == 2 and self.shape[0] > 1 and self.shape[1] > 1
 
     def __getitem__(self, key) -> "Expression":
@@ -582,17 +580,19 @@ def cast_to_const(expr: "Expression"):
 
     @staticmethod
     def broadcast(lh_expr: "Expression", rh_expr: "Expression"):
-        """Broacast the binary operator.
-        """
+        """Broadcast the binary operator."""
         lh_expr = Expression.cast_to_const(lh_expr)
         rh_expr = Expression.cast_to_const(rh_expr)
+        # Promote.
         if lh_expr.is_scalar() and not rh_expr.is_scalar():
-            lh_expr = cvxtypes.promote()(lh_expr, rh_expr.shape)
+            lh_expr = cp.promote(lh_expr, rh_expr.shape)
         elif rh_expr.is_scalar() and not lh_expr.is_scalar():
-            rh_expr = cvxtypes.promote()(rh_expr, lh_expr.shape)
-        # Broadcasting.
+            rh_expr = cp.promote(rh_expr, lh_expr.shape)
+        # TODO: remove special case once CPP backend is removed
+        elif lh_expr.is_scalar() and rh_expr.is_scalar():
+            return lh_expr, rh_expr
+        # TODO: cleanup once CPP backend is removed
         if lh_expr.ndim == 2 and rh_expr.ndim == 2:
-            # Replicate dimensions of size 1.
             dims = [max(lh_expr.shape[i], rh_expr.shape[i]) for i in range(2)]
             # Broadcast along dim 0.
             if lh_expr.shape[0] == 1 and lh_expr.shape[0] < dims[0]:
@@ -604,6 +604,13 @@ def broadcast(lh_expr: "Expression", rh_expr: "Expression"):
                 lh_expr = lh_expr @ np.ones((1, dims[1]))
             if rh_expr.shape[1] == 1 and rh_expr.shape[1] < dims[1]:
                 rh_expr = rh_expr @ np.ones((1, dims[1]))
+        # Broadcasting.
+        elif lh_expr.ndim >= 3 or rh_expr.ndim >= 3 or lh_expr.ndim != rh_expr.ndim:
+            output_shape = np.broadcast_shapes(lh_expr.shape, rh_expr.shape)
+            if lh_expr.shape != output_shape:
+                lh_expr = cp.broadcast_to(lh_expr, output_shape)
+            if rh_expr.shape != output_shape:
+                rh_expr = cp.broadcast_to(rh_expr, output_shape)
         return lh_expr, rh_expr
 
     @_cast_other
diff --git a/cvxpy/lin_ops/canon_backend.py b/cvxpy/lin_ops/canon_backend.py
index 3657e318e4..75400f3f2c 100644
--- a/cvxpy/lin_ops/canon_backend.py
+++ b/cvxpy/lin_ops/canon_backend.py
@@ -332,6 +332,7 @@ def get_func(self, func_name: str) -> Callable:
             "sum": self.sum_op,
             "mul": self.mul,
             "promote": self.promote,
+            "broadcast_to": self.broadcast_to,
             "neg": self.neg,
             "mul_elem": self.mul_elem,
             "sum_entries": self.sum_entries,
@@ -384,6 +385,14 @@ def promote(lin: LinOp, view: TensorView) -> TensorView:
         Promote view by repeating along axis 0 (rows)
         """
         pass  # noqa
+    
+    @staticmethod
+    @abstractmethod
+    def broadcast_to(lin: LinOp, view: TensorView) -> TensorView:
+        """
+        Broadcast view to a new shape.
+        """
+        pass # noqa
 
     @staticmethod
     def neg(_lin: LinOp, view: TensorView) -> TensorView:
@@ -766,6 +775,18 @@ def func(x):
         view.apply_all(func)
         return view
 
+    @staticmethod
+    def broadcast_to(lin: LinOp, view: NumPyTensorView) -> NumPyTensorView:
+        """
+        Broadcast view by calling np.broadcast_to on the rows and indexing the view.
+        """
+        broadcast_shape = lin.shape
+        original_shape = lin.args[0].shape
+        rows = np.arange(np.prod(original_shape, dtype=int)).reshape(original_shape, order='F')
+        rows = np.broadcast_to(rows, broadcast_shape).flatten(order="F")
+        view.select_rows(rows)
+        return view
+    
     def mul_elem(self, lin: LinOp, view: NumPyTensorView) -> NumPyTensorView:
         """
         Given (A, b) in view and constant data d, return (A*d, b*d).
@@ -1206,6 +1227,18 @@ def promote(lin: LinOp, view: SciPyTensorView) -> SciPyTensorView:
         view.select_rows(rows)
         return view
 
+    @staticmethod
+    def broadcast_to(lin: LinOp, view: SciPyTensorView) -> SciPyTensorView:
+        """
+        Broadcast view by calling np.broadcast_to on the rows and indexing the view.
+        """
+        broadcast_shape = lin.shape
+        original_shape = lin.args[0].shape
+        rows = np.arange(np.prod(original_shape, dtype=int)).reshape(original_shape, order='F')
+        rows = np.broadcast_to(rows, broadcast_shape).flatten(order="F")
+        view.select_rows(rows)
+        return view
+    
     def mul_elem(self, lin: LinOp, view: SciPyTensorView) -> SciPyTensorView:
         """
         Given (A, b) in view and constant data d, return (A*d, b*d).
diff --git a/cvxpy/lin_ops/lin_op.py b/cvxpy/lin_ops/lin_op.py
index 5cb757927c..bfd9f07510 100644
--- a/cvxpy/lin_ops/lin_op.py
+++ b/cvxpy/lin_ops/lin_op.py
@@ -40,6 +40,9 @@ def __repr__(self) -> str:
 # Promoting a scalar expression.
 # Data: None
 PROMOTE = "promote"
+# Broadcasting an expression.
+# Data: Shape to broadcast to.
+BROADCAST_TO = "broadcast_to"
 # Multiplying an expression by a constant.
 # Data: LinOp evaluating to the left hand multiple.
 MUL = "mul"
diff --git a/cvxpy/lin_ops/lin_utils.py b/cvxpy/lin_ops/lin_utils.py
index e42842313e..da0696cc2c 100644
--- a/cvxpy/lin_ops/lin_utils.py
+++ b/cvxpy/lin_ops/lin_utils.py
@@ -368,6 +368,12 @@ def promote(operator, shape: Tuple[int, ...]):
     return lo.LinOp(lo.PROMOTE, shape, [operator], None)
 
 
+def broadcast_to(operators, shape: Tuple[int, ...]):
+    """Broadcasts operators to a common shape.
+    """
+    return lo.LinOp(lo.BROADCAST_TO, shape, operators, [])
+
+
 def sum_entries(operator, shape: Tuple[int, ...], axis=None, keepdims=None):
     """Sum the entries of an operator.
 
diff --git a/cvxpy/problems/objective.py b/cvxpy/problems/objective.py
index d8cfbe63bd..5539ce84ee 100644
--- a/cvxpy/problems/objective.py
+++ b/cvxpy/problems/objective.py
@@ -40,6 +40,8 @@ class Objective(u.Canonical):
 
     def __init__(self, expr) -> None:
         self.args = [Expression.cast_to_const(expr)]
+        # Needed for Canonical._aggregate_metrics.
+        self.ndim = 0
         # Validate that the objective resolves to a scalar.
         if not self.args[0].is_scalar():
             raise ValueError("The '%s' objective must resolve to a scalar."
diff --git a/cvxpy/problems/problem.py b/cvxpy/problems/problem.py
index a1b1d4be5f..e4a065e7af 100644
--- a/cvxpy/problems/problem.py
+++ b/cvxpy/problems/problem.py
@@ -176,6 +176,29 @@ def __init__(
         self._compilation_time: Optional[float] = None
         self._solve_time: Optional[float] = None
         self.args = [self._objective, self._constraints]
+        # Needed for _aggregate_metrics.
+        self.ndim = 0
+
+    @perf.compute_once
+    def _aggregate_metrics(self) -> dict:
+        """
+        Aggregates and caches metrics for expression trees in self.args. So far
+        metrics include the maximum dimensionality ('max_ndim') and whether
+        all sub-expressions support C++ ('all_support_cpp').
+
+        """
+        max_ndim = self.ndim
+        cpp_support = self._supports_cpp()
+
+        for arg in [self._objective] + self._constraints:
+            max_ndim = max(max_ndim, arg._max_ndim())
+            cpp_support = cpp_support and arg._all_support_cpp()
+
+        metrics = {
+            "max_ndim": max_ndim,
+            "all_support_cpp": cpp_support
+        }
+        return metrics
 
     @property
     def value(self):
diff --git a/cvxpy/reductions/complex2real/canonicalizers/__init__.py b/cvxpy/reductions/complex2real/canonicalizers/__init__.py
index 99e7b2b79d..d86a2aa5b9 100644
--- a/cvxpy/reductions/complex2real/canonicalizers/__init__.py
+++ b/cvxpy/reductions/complex2real/canonicalizers/__init__.py
@@ -22,6 +22,7 @@
 from cvxpy.atoms.affine.add_expr import AddExpression
 from cvxpy.atoms.affine.binary_operators import (DivExpression, MulExpression,
                                                  multiply,)
+from cvxpy.atoms.affine.broadcast_to import broadcast_to
 from cvxpy.atoms.affine.diag import diag_mat, diag_vec
 from cvxpy.atoms.affine.hstack import Hstack
 from cvxpy.atoms.affine.index import index, special_index
@@ -70,6 +71,7 @@
     index: separable_canon,
     special_index: separable_canon,
     Promote: separable_canon,
+    broadcast_to: separable_canon,
     reshape: separable_canon,
     Sum: separable_canon,
     trace: trace_canon,
diff --git a/cvxpy/tests/test_atoms.py b/cvxpy/tests/test_atoms.py
index 73f1da9b45..334119378c 100644
--- a/cvxpy/tests/test_atoms.py
+++ b/cvxpy/tests/test_atoms.py
@@ -1065,6 +1065,7 @@ def test_cvar(self) -> None:
         # Check that the CVaR constraint is satisfied
         cvar_value = cp.cvar(A @ x.value, beta).value
         self.assertTrue(cvar_value <= kappa)
+
     def test_index(self) -> None:
         """Test the copy function for index.
         """
diff --git a/cvxpy/tests/test_expressions.py b/cvxpy/tests/test_expressions.py
index 8eaeac3a08..1a3182c3b4 100644
--- a/cvxpy/tests/test_expressions.py
+++ b/cvxpy/tests/test_expressions.py
@@ -20,7 +20,12 @@
 import pytest
 import scipy.sparse as sp
 from hypothesis import assume, given
-from hypothesis.extra.numpy import arrays, basic_indices, integer_array_indices
+from hypothesis.extra.numpy import (
+    arrays,
+    basic_indices,
+    broadcastable_shapes,
+    integer_array_indices,
+)
 
 import cvxpy as cp
 import cvxpy.interface.matrix_utilities as intf
@@ -764,13 +769,6 @@ def test_matmul_expression(self) -> None:
             q = self.A .__matmul__(self.B)
             self.assertTrue(q.is_quadratic())
 
-        # # Nonaffine times nonconstant raises error
-        # with warnings.catch_warnings():
-        #     warnings.simplefilter("ignore")
-        #     with self.assertRaises(Exception) as cm:
-        #         (self.A.__matmul__(self.B).__matmul__(self.A))
-        #     self.assertEqual(str(cm.exception), "Cannot multiply UNKNOWN and AFFINE.")
-
         # Constant expressions
         T = Constant([[1, 2, 3], [3, 5, 5]])
         exp = (T + T) .__matmul__(self.B)
@@ -818,7 +816,7 @@ def test_div_expression(self) -> None:
             (self.x/[2, 2, 3])
         print(cm.exception)
         self.assertRegex(str(cm.exception),
-                         "Incompatible shapes for division.*")
+                         "Incompatible shapes for division")
 
         c = Constant([3.0, 4.0, 12.0])
         self.assertItemsAlmostEqual(
@@ -855,12 +853,6 @@ def test_div_expression(self) -> None:
         A = np.ones((3, 3)) / c
         self.assertItemsAlmostEqual(A, expr.value)
 
-        with self.assertRaises(Exception) as cm:
-            (x/c[:, 0])
-        print(cm.exception)
-        self.assertRegex(str(cm.exception),
-                         "Incompatible shapes for division.*")
-
     # Test the NegExpression class.
     def test_neg_expression(self) -> None:
         # Vectors
@@ -1653,7 +1645,7 @@ def test_nd_integer_index(self, s) -> None:
         assert np.allclose(expr.value, y)
 
     @given(axis=basic_indices(shape=(2,2,2), allow_newaxis=True))
-    def test_nd__basic_index(self, axis) -> None:
+    def test_nd_basic_index(self, axis) -> None:
         # Skip examples with 0-d output. TODO allow 0-d expressions in cvxpy.
         def is_zero_dim_output(axis):
             return 0 in self.target[axis].shape
@@ -1713,3 +1705,84 @@ def test_nd_transpose(self) -> None:
         prob = cp.Problem(self.obj, [expr == y])
         prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
         assert np.allclose(expr.value, y)
+
+    @pytest.mark.parametrize("shapes", [((3),(253, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((1),(5, 4)),
+                                        ((4),(5, 4)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = cp.broadcast_to(x, shape=shapes[1])
+        assert y.shape == shapes[1]
+        prob = cp.Problem(cp.Minimize(cp.sum(y)), [y == 1])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(y.value, 1)
+
+    @pytest.mark.parametrize("shapes", [((3), (2, 2, 2)),
+                                        ((3), (4)),
+                                        ((2, 1),(8, 4, 3))])
+    def test_nd_broadcast_error(self, shapes) -> None:
+        error_str = "operands could not be broadcast together"
+        with pytest.raises(Exception, match=error_str):
+            x = cp.Variable(shapes[0])
+            y = cp.broadcast_to(x, shape=shapes[1])
+            assert y.shape is not shapes[1]
+
+    @pytest.mark.parametrize("shapes", [((5),(3, 1)),
+                                        ((15, 1),(8)),
+                                        ((3), (2, 1))])
+    def test_no_segfault_multiply(self, shapes) -> None:
+        """
+        This test ensures that no error is raised when
+        multiplying two broadcastable array shapes <= 2.
+        Previously this would cause a segfault in the CPP backend.
+        """
+        x = cp.Variable(shapes[0])
+        target = np.arange(np.prod(shapes[0])).reshape(shapes[0])
+        a = np.arange(np.prod(shapes[1])).reshape(shapes[1])
+        b = np.arange(np.prod(shapes[1])).reshape(shapes[1])
+        obj = cp.sum(cp.max(cp.multiply(a, x) + b, axis=0))
+        prob = cp.Problem(cp.Minimize(obj), [x == target])
+        prob.solve()
+        assert np.allclose(x.value, target)
+
+    @pytest.mark.parametrize("shapes", [((3),(252, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((2, 1, 2), (2, 3, 2)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_multiply_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = np.arange(np.prod(shapes[1])).reshape(shapes[1])
+        expr = cp.multiply(x, y)
+        target = np.arange(np.prod(shapes[0])).reshape(shapes[0])
+        prob = cp.Problem(cp.Minimize(cp.sum(expr)), [expr == target * y])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(x.value, target)
+
+    @pytest.mark.parametrize("shapes", [((3),(252, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((2, 1, 2), (2, 3, 2)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_add_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = np.arange(np.prod(shapes[1])).reshape(shapes[1])
+        expr = x + y
+        target = np.arange(np.prod(shapes[0])).reshape(shapes[0])
+        prob = cp.Problem(cp.Minimize(cp.sum(expr)), [expr == target + y])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(x.value, target)
+
+    @given(shape=broadcastable_shapes((8, 14, 8, 28), max_dims=4))
+    def test_nd_broadcast_generated(self, shape) -> None:
+        x = cp.Variable((8, 14, 8, 28))
+        y = np.arange(np.prod((shape))).reshape((shape))
+        expr = x - y
+        target = np.arange(np.prod((8,14,8,28))).reshape(8,14,8,28)
+        prob = cp.Problem(cp.Minimize(cp.sum(expr)), [expr == target - y])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(x.value, target)
diff --git a/cvxpy/tests/test_python_backends.py b/cvxpy/tests/test_python_backends.py
index 2502725cb6..00a55a3f74 100644
--- a/cvxpy/tests/test_python_backends.py
+++ b/cvxpy/tests/test_python_backends.py
@@ -551,6 +551,102 @@ def test_promote(self, backend):
         # Note: view is edited in-place:
         assert out_view.get_tensor_representation(0, 1) == view.get_tensor_representation(0, 1)
 
+    def test_broadcast_to_rows(self, backend):
+        """
+        define x = Variable(3) with
+        [x1, x2, x3]
+
+        x is represented as eye(3) in the A matrix, i.e.,
+         x1 x2 x3
+        [[1  0  0],
+         [0  1  0],
+         [0  0  1]]
+        
+        broadcast_to(x, (2, 3)) means we repeat every variable twice along the row axis.
+
+        Thus we expect the following A matrix:
+        
+         x1 x2 x3
+        [[1  0  0],
+         [1  0  0],
+         [0  1  0],
+         [0  1  0]
+         [0  0  1],
+         [0  0  1]]
+        """
+        variable_lin_op = linOpHelper((3,), type="variable", data=1)
+        view = backend.process_constraint(variable_lin_op, backend.get_empty_view())
+
+        # cast to numpy
+        view_A = view.get_tensor_representation(0, 3)
+        view_A = sp.coo_matrix((view_A.data, (view_A.row, view_A.col)), shape=(3, 3)).toarray()
+        assert np.all(view_A == np.eye(3))
+
+        broadcast_lin_op = linOpHelper((2,3), data=(2,3), args=[variable_lin_op])
+        out_view = backend.broadcast_to(broadcast_lin_op, view)
+        A = out_view.get_tensor_representation(0, 3)
+
+        # cast to numpy
+        A = sp.coo_matrix((A.data, (A.row, A.col)), shape=(6, 3)).toarray()
+        expected = np.array([[1, 0, 0],
+                             [1, 0, 0],
+                             [0, 1, 0],
+                             [0, 1, 0],
+                             [0, 0, 1],
+                             [0, 0, 1]])
+        assert np.all(A == expected)
+
+        # Note: view is edited in-place:
+        assert out_view.get_tensor_representation(0, 1) == view.get_tensor_representation(0, 1)
+
+    def test_broadcast_to_cols(self, backend):
+        """
+        define x = Variable((2,1)) with
+        [[x1], 
+         [x2]]
+
+        x is represented as eye(2) in the A matrix, i.e.,
+         x1 x2
+        [[1  0],
+         [0  1]]
+        
+        broadcast_to(x, (2, 3)) means we tile the variables three times along the rows
+
+        Thus we expect the following A matrix:
+        
+         x1 x2
+        [[1  0],
+         [0  1],
+         [1  0],
+         [0  1]
+         [1  0],
+         [0  1]]
+        """
+        variable_lin_op = linOpHelper((2,1), type="variable", data=1)
+        view = backend.process_constraint(variable_lin_op, backend.get_empty_view())
+
+        # cast to numpy
+        view_A = view.get_tensor_representation(0, 2)
+        view_A = sp.coo_matrix((view_A.data, (view_A.row, view_A.col)), shape=(2, 2)).toarray()
+        assert np.all(view_A == np.eye(2))
+
+        broadcast_lin_op = linOpHelper((2,3), data=(2,3), args=[variable_lin_op])
+        out_view = backend.broadcast_to(broadcast_lin_op, view)
+        A = out_view.get_tensor_representation(0, 3)
+
+        # cast to numpy
+        A = sp.coo_matrix((A.data, (A.row, A.col)), shape=(6, 2)).toarray()
+        expected = np.array([[1, 0],
+                             [0, 1],
+                             [1, 0],
+                             [0, 1],
+                             [1, 0],
+                             [0, 1]])
+        assert np.all(A == expected)
+
+        # Note: view is edited in-place:
+        assert out_view.get_tensor_representation(0, 1) == view.get_tensor_representation(0, 1)
+
     def test_hstack(self, backend):
         """
         define x,y = Variable((1,)), Variable((1,))
@@ -2161,6 +2257,69 @@ def test_nd_index(self, backend):
         # Note: view is edited in-place:
         assert out_view.get_tensor_representation(0, 1) == view.get_tensor_representation(0, 1)
 
+    def test_nd_broadcast_to(self, backend):
+        """
+        define x = Variable((2,1,2)) with
+        [[x11, x12], 
+         [x21, x22]]
+
+        x is represented as eye(4) in the A matrix, i.e.,
+         x11 x21 x12 x22
+        [[1   0   0   0],
+         [0   1   0   0],
+         [0   0   1   0],
+         [0   0   0   1]]
+        
+        broadcast_to(x, (2, 3, 2)) means we repeat columns of x three times each subsequently.
+
+        Thus we expect the following A matrix:
+        
+        x11 x21 x12 x22
+        [[1   0   0   0],
+         [0   1   0   0],
+         [1   0   0   0],
+         [0   1   0   0],
+         [1   0   0   0],
+         [0   1   0   0],
+
+         [0   0   1   0],
+         [0   0   0   1],
+         [0   0   1   0],
+         [0   0   0   1],
+         [0   0   1   0],
+         [0   0   0   1]]
+        """
+        variable_lin_op = linOpHelper((2,1,2), type="variable", data=1)
+        view = backend.process_constraint(variable_lin_op, backend.get_empty_view())
+
+        # cast to numpy
+        view_A = view.get_tensor_representation(0, 4)
+        view_A = sp.coo_matrix((view_A.data, (view_A.row, view_A.col)), shape=(4, 4)).toarray()
+        assert np.all(view_A == np.eye(4))
+
+        broadcast_lin_op = linOpHelper((2,3,2), data=(2,3,2), args=[variable_lin_op])
+        out_view = backend.broadcast_to(broadcast_lin_op, view)
+        A = out_view.get_tensor_representation(0, 12)
+
+        # cast to numpy
+        A = sp.coo_matrix((A.data, (A.row, A.col)), shape=(12, 4)).toarray()
+        expected = np.array([[1, 0, 0, 0],
+                             [0, 1, 0, 0],
+                             [1, 0, 0, 0],
+                             [0, 1, 0, 0],
+                             [1, 0, 0, 0],
+                             [0, 1, 0, 0],
+                             [0, 0, 1, 0],
+                             [0, 0, 0, 1],
+                             [0, 0, 1, 0],
+                             [0, 0, 0, 1],
+                             [0, 0, 1, 0],
+                             [0, 0, 0, 1]])
+        assert np.all(A == expected)
+
+        # Note: view is edited in-place:
+        assert out_view.get_tensor_representation(0, 1) == view.get_tensor_representation(0, 1)
+
 
 class TestParametrizedND_Backends:
     @staticmethod
diff --git a/cvxpy/utilities/canonical.py b/cvxpy/utilities/canonical.py
index 21409f9a40..1614aceb93 100644
--- a/cvxpy/utilities/canonical.py
+++ b/cvxpy/utilities/canonical.py
@@ -172,7 +172,7 @@ def _aggregate_metrics(self) -> dict:
 
         for arg in self.args:
             max_ndim = max(max_ndim, arg._max_ndim())
-            cpp_support = cpp_support and arg._supports_cpp()
+            cpp_support = cpp_support and arg._all_support_cpp()
 
         metrics = {
             "max_ndim": max_ndim,


Reviews:
user: login: Transurgeon
body: I think this is ready for a first round of reviews. I have still yet to add backend specific tests which could help understand the operator (although it is mostly about duplicating existing tensors along the rows). 
Things that are open for discussion, do we want to support broadcasting for both operands? (i.e. cp.multiply((3,1), (5)) is allowed and will give an (3,5) expression)
this means we will need to broadcast both the constant data and the variable/expr which differs from the current behavior (although matches the NumPy API for [broadcasting](https://numpy.org/doc/stable/user/basics.broadcasting.html#broadcastable-arrays))
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-02-22T08:08:33Z
user: login: SteveDiamond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T18:00:17Z
user: login: SteveDiamond
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-07T18:09:02Z
user: login: Transurgeon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T18:59:20Z
user: login: Transurgeon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-07T19:08:13Z
user: login: SteveDiamond
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T20:35:21Z
user: login: Transurgeon
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-09T21:51:28Z

Review comments:
diff_hunk: @@ -1713,3 +1711,61 @@ def test_nd_transpose(self) -> None:
         prob = cp.Problem(self.obj, [expr == y])
         prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
         assert np.allclose(expr.value, y)
+
+    @pytest.mark.parametrize("shapes", [((3),(253, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((1),(5, 4)),
+                                        ((4),(5, 4)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = cp.broadcast_to(x, shape=shapes[1])
+        assert y.shape == shapes[1]
+        prob = cp.Problem(cp.Minimize(cp.sum(y)), [y == 1])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(y.value, 1)
+
+    @pytest.mark.parametrize("shapes", [((3), (2, 2, 2)),
+                                        ((3), (4)),
+                                        ((2, 1),(8, 4, 3))])
+    def test_nd_broadcast_error(self, shapes) -> None:
+        error_str = "operands could not be broadcast together"
+        with pytest.raises(Exception, match=error_str):
+            x = cp.Variable(shapes[0])
+            y = cp.broadcast_to(x, shape=shapes[1])
+            assert y.shape == shapes[1]
+
+    def test_no_segfault_multiply(self) -> None:
+        error_str = "Cannot multiply expressions with dimensions"
+        with pytest.raises(Exception, match=error_str):
+            x = cp.Variable(5)
+            a = np.array([1,2,3]).reshape(-1, 1)
+            b = np.array([1,2,3]).reshape(-1, 1)        
+            obj = cp.sum(cp.max(cp.multiply(a, x) + b, axis=0))
+            prob = cp.Problem(cp.Minimize(obj), [])
+            prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+
+    @pytest.mark.parametrize("shapes", [((3),(253, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((1),(5, 4)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_multiply_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = np.arange(np.prod(shapes[1])).reshape(shapes[1])
+        expr = cp.multiply(x, y)
+        prob = cp.Problem(cp.Minimize(cp.sum(expr)), [x == 1])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(expr.value, y)
path: cvxpy/tests/test_expressions.py
user: login: Transurgeon
body: this test shows that nd broadcasting with multiply doesn't raise an unwanted error. 
But I am still not sure if the solution is correct. Any idea for better tests? and how to validate that its working as expected (maybe compare solution to numpy multiply?)
created_at: 2025-02-22T08:03:02Z
author_association: COLLABORATOR
diff_hunk: @@ -604,6 +601,13 @@ def broadcast(lh_expr: "Expression", rh_expr: "Expression"):
                 lh_expr = lh_expr @ np.ones((1, dims[1]))
             if rh_expr.shape[1] == 1 and rh_expr.shape[1] < dims[1]:
                 rh_expr = rh_expr @ np.ones((1, dims[1]))
+        # Broadcasting.
+        if lh_expr.ndim >= 3 or rh_expr.ndim >= 3:
+            output_shape = np.broadcast_shapes(lh_expr.shape, rh_expr.shape)
+            if lh_expr.shape != output_shape:
+                lh_expr = cp.broadcast_to(lh_expr, output_shape)
+            if rh_expr.shape != output_shape:
+                rh_expr = cp.broadcast_to(rh_expr, output_shape)
path: cvxpy/expressions/expression.py
user: login: Transurgeon
body: added this as special case to ensure that the SCIPY backend is used to call cp.broadcast_to, otherwise it will raise an error that the CPP backend didn't implement this LinOp.
created_at: 2025-02-22T08:04:18Z
author_association: COLLABORATOR
diff_hunk: @@ -262,19 +259,25 @@ def is_atom_quasiconcave(self) -> bool:
             arg.is_nonpos() for arg in self.args)
 
     def numeric(self, values):
-        """Multiplies the values elementwise.
-        """
+        """Multiplies the values elementwise."""
         if sp.issparse(values[0]):
             return values[0].multiply(values[1])
         elif sp.issparse(values[1]):
             return values[1].multiply(values[0])
         else:
             return np.multiply(values[0], values[1])
 
+    def validate_arguments(self):
+        """Validate that the arguments are broadcastable."""
+        if not self.args[0].is_scalar() and not self.args[1].is_scalar():
+            output_shape = np.broadcast_shapes(self.args[0].shape, self.args[1].shape)
+            if self.args[0].shape != output_shape and self.args[1].shape != output_shape:
+                raise ValueError("Cannot multiply expressions with dimensions %s and %s" %
+                                (self.args[0].shape, self.args[1].shape))
+
path: cvxpy/atoms/affine/binary_operators.py
user: login: Transurgeon
body: this function adds validation for better error messages when calling multiply.
created_at: 2025-02-22T08:05:20Z
author_association: COLLABORATOR
diff_hunk: @@ -1713,3 +1705,85 @@ def test_nd_transpose(self) -> None:
         prob = cp.Problem(self.obj, [expr == y])
         prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
         assert np.allclose(expr.value, y)
+
+    @pytest.mark.parametrize("shapes", [((3),(253, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((1),(5, 4)),
+                                        ((4),(5, 4)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = cp.broadcast_to(x, shape=shapes[1])
+        assert y.shape == shapes[1]
+        prob = cp.Problem(cp.Minimize(cp.sum(y)), [y == 1])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(y.value, 1)
+
+    @pytest.mark.parametrize("shapes", [((3), (2, 2, 2)),
+                                        ((3), (4)),
+                                        ((2, 1),(8, 4, 3))])
+    def test_nd_broadcast_error(self, shapes) -> None:
+        error_str = "operands could not be broadcast together"
+        with pytest.raises(Exception, match=error_str):
+            x = cp.Variable(shapes[0])
+            y = cp.broadcast_to(x, shape=shapes[1])
+            assert y.shape is not shapes[1]
+
+    @pytest.mark.parametrize("shapes", [((5),(3, 1)),
+                                        ((15, 1),(8)),
+                                        ((3), (2, 1))])
+    def test_segfault_multiply(self, shapes) -> None:
path: cvxpy/tests/test_expressions.py
user: login: SteveDiamond
body: Can you say more about this test? Would these shapes be compatible with numpy broadcasting rules?
created_at: 2025-03-07T18:00:17Z
author_association: COLLABORATOR
diff_hunk: @@ -1713,3 +1705,85 @@ def test_nd_transpose(self) -> None:
         prob = cp.Problem(self.obj, [expr == y])
         prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
         assert np.allclose(expr.value, y)
+
+    @pytest.mark.parametrize("shapes", [((3),(253, 253, 3)),
+                                        ((7, 1, 5),(8, 7, 6, 5)),
+                                        ((1),(5, 4)),
+                                        ((4),(5, 4)),
+                                        ((15, 1, 5), (15, 3, 5)),
+                                        ((3, 5), (15, 3, 5)),
+                                        ((3, 1), (15, 3, 5))])
+    def test_nd_broadcast(self, shapes) -> None:
+        x = cp.Variable(shapes[0])
+        y = cp.broadcast_to(x, shape=shapes[1])
+        assert y.shape == shapes[1]
+        prob = cp.Problem(cp.Minimize(cp.sum(y)), [y == 1])
+        prob.solve(canon_backend=cp.SCIPY_CANON_BACKEND)
+        assert np.allclose(y.value, 1)
+
+    @pytest.mark.parametrize("shapes", [((3), (2, 2, 2)),
+                                        ((3), (4)),
+                                        ((2, 1),(8, 4, 3))])
+    def test_nd_broadcast_error(self, shapes) -> None:
+        error_str = "operands could not be broadcast together"
+        with pytest.raises(Exception, match=error_str):
+            x = cp.Variable(shapes[0])
+            y = cp.broadcast_to(x, shape=shapes[1])
+            assert y.shape is not shapes[1]
+
+    @pytest.mark.parametrize("shapes", [((5),(3, 1)),
+                                        ((15, 1),(8)),
+                                        ((3), (2, 1))])
+    def test_segfault_multiply(self, shapes) -> None:
path: cvxpy/tests/test_expressions.py
user: login: Transurgeon
body: yes they are compatible with numpy broadcasting. And these tests would pass for the SCIPY backend. However, I was not able to enforce the SCIPY backend to be used in these cases. (CPP would raise errors because it doesn't have the broadcast_to atom implemented).
created_at: 2025-03-07T18:59:19Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,81 @@
+"""
+Copyright, the CVXPY authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+"""
+
+from typing import List, Optional, Tuple
+
+import numpy as np
+
+import cvxpy.lin_ops.lin_op as lo
+import cvxpy.lin_ops.lin_utils as lu
+from cvxpy.atoms.affine.affine_atom import AffAtom
+from cvxpy.constraints.constraint import Constraint
+
+
+class broadcast_to(AffAtom):
+    """Broadcast the expression given a shape input"""
+
+    def __init__(self, expr, shape) -> None:
+        self.broadcast_shape = shape
+        self._shape = expr.shape
+        super(broadcast_to, self).__init__(expr)
+
+    def _supports_cpp(self) -> bool:
+        return False
path: cvxpy/atoms/affine/broadcast_to.py
user: login: Transurgeon
body: I've added this method, but I was still getting tests going through the CPP backend.
I think it's because the broadcast_to atom is being called from another atom (i.e. multiply), but not sure. 
Ideally we should fix this, but maybe for another PR.
created_at: 2025-03-07T19:08:12Z
author_association: COLLABORATOR
diff_hunk: @@ -605,7 +605,7 @@ def broadcast(lh_expr: "Expression", rh_expr: "Expression"):
             if rh_expr.shape[1] == 1 and rh_expr.shape[1] < dims[1]:
                 rh_expr = rh_expr @ np.ones((1, dims[1]))
         # Broadcasting.
-        elif lh_expr.ndim >= 3 or rh_expr.ndim >= 3 or lh_expr.ndim != rh_expr.ndim:
+        else:
path: cvxpy/expressions/expression.py
user: login: SteveDiamond
body: Changing this to else is not right. We want to avoid using the broadcast atom when not necessary so that people can still use the CPP backend. Can you change this back?
created_at: 2025-03-09T20:35:21Z
author_association: COLLABORATOR
diff_hunk: @@ -605,7 +605,7 @@ def broadcast(lh_expr: "Expression", rh_expr: "Expression"):
             if rh_expr.shape[1] == 1 and rh_expr.shape[1] < dims[1]:
                 rh_expr = rh_expr @ np.ones((1, dims[1]))
         # Broadcasting.
-        elif lh_expr.ndim >= 3 or rh_expr.ndim >= 3 or lh_expr.ndim != rh_expr.ndim:
+        else:
path: cvxpy/expressions/expression.py
user: login: Transurgeon
body: yeah agreed, will do.
created_at: 2025-03-09T21:51:28Z
author_association: COLLABORATOR

PR comments:
user: login: CLAassistant
created_at: 2025-02-21T03:20:02Z
body: [![CLA assistant check](https://cla-assistant.io/pull/badge/signed)](https://cla-assistant.io/cvxpy/cvxpy?pullRequest=2724) <br/>All committers have signed the CLA.
author_association: NONE
user: login: SteveDiamond
created_at: 2025-03-07T18:09:53Z
body: This is a really elegant solution for the backend! To be clear, in this MR we are not handling cases such as (5) + (5,1) -> (5,5)? We should still handle these cases but it can be in a separate MR.
author_association: COLLABORATOR
user: login: SteveDiamond
created_at: 2025-03-07T21:15:09Z
body: There is a bug in ``_aggregate_metrics``. It's not recursive. It should be
```
    @pu.compute_once
    def _aggregate_metrics(self) -> dict:
        """
        Aggregates and caches metrics for expression trees in self.args. So far
        metrics include the maximum dimensionality ('max_ndim') and whether
        all sub-expressions support C++ ('all_support_cpp').

        """
        max_ndim = self.ndim
        cpp_support = self._supports_cpp()

        for arg in self.args:
            max_ndim = max(max_ndim, arg._max_ndim())
            cpp_support = cpp_support and arg._all_support_cpp()

        metrics = {
            "max_ndim": max_ndim,
            "all_support_cpp": cpp_support
        }
        return metrics
```
author_association: COLLABORATOR