@@ -32,6 +32,63 @@ def normalize_shape(shape):
3232 shape = tuple (shape )
3333 return (1 ,) * (2 - len (shape )) + shape
3434
35+
36+ def _dense_to_csr_args (A ):
37+ """Convert dense matrix to CSR data/indices/indptr arrays for C engine."""
38+ A_csr = sparse .csr_matrix (np .asarray (A , dtype = np .float64 ))
39+ return (
40+ A_csr .data .astype (np .float64 , copy = False ),
41+ A_csr .indices .astype (np .int32 , copy = False ),
42+ A_csr .indptr .astype (np .int32 , copy = False ),
43+ )
44+
45+
46+ def _build_kron_left_csr (C , p , q ):
47+ """Build sparse CSR matrix M such that vec(kron(C, X)) = M @ vec(X).
48+
49+ C is m x n constant, X is p x q variable. Uses Fortran (column-major) order.
50+ M has shape (m*p*n*q, p*q) with at most nnz(C)*p*q nonzeros.
51+ """
52+ m , n = C .shape
53+ C = np .asarray (C , dtype = np .float64 )
54+
55+ i_arr , j_arr = np .arange (m ), np .arange (n )
56+ k_arr , l_arr = np .arange (p ), np .arange (q )
57+
58+ ii , jj , kk , ll = np .meshgrid (i_arr , j_arr , k_arr , l_arr , indexing = 'ij' )
59+ rows = (jj * q + ll ) * (m * p ) + ii * p + kk
60+ cols = ll * p + kk
61+ vals = C [ii , jj ]
62+
63+ rows , cols , vals = rows .ravel (), cols .ravel (), vals .ravel ()
64+ mask = vals != 0
65+ return sparse .csr_matrix (
66+ (vals [mask ], (rows [mask ], cols [mask ])), shape = (m * p * n * q , p * q )
67+ )
68+
69+
70+ def _build_kron_right_csr (C , mx , nx ):
71+ """Build sparse CSR matrix M such that vec(kron(X, C)) = M @ vec(X).
72+
73+ X is mx x nx variable, C is p x q constant. Uses Fortran (column-major) order.
74+ """
75+ p , q = C .shape
76+ C = np .asarray (C , dtype = np .float64 )
77+
78+ i_arr , j_arr = np .arange (mx ), np .arange (nx )
79+ k_arr , l_arr = np .arange (p ), np .arange (q )
80+
81+ ii , jj , kk , ll = np .meshgrid (i_arr , j_arr , k_arr , l_arr , indexing = 'ij' )
82+ rows = (jj * q + ll ) * (mx * p ) + ii * p + kk
83+ cols = jj * mx + ii
84+ vals = C [kk , ll ]
85+
86+ rows , cols , vals = rows .ravel (), cols .ravel (), vals .ravel ()
87+ mask = vals != 0
88+ return sparse .csr_matrix (
89+ (vals [mask ], (rows [mask ], cols [mask ])), shape = (mx * p * nx * q , mx * nx )
90+ )
91+
3592def _chain_add (children ):
3693 """Chain multiple children with binary adds: a + b + c -> add(add(a, b), c)."""
3794 result = children [0 ]
@@ -46,7 +103,7 @@ def _convert_matmul(expr, children):
46103
47104 if left_arg .is_constant ():
48105 A = left_arg .value
49-
106+
50107 if sparse .issparse (A ):
51108 if not isinstance (A , sparse .csr_matrix ):
52109 A = sparse .csr_matrix (A )
@@ -60,21 +117,31 @@ def _convert_matmul(expr, children):
60117 A .shape [1 ],
61118 )
62119 else :
120+ A = np .asarray (A , dtype = np .float64 )
63121 m , n = normalize_shape (A .shape )
64- return _diffengine .make_dense_left_matmul (
122+ return _diffengine .make_sparse_left_matmul (
65123 children [1 ],
66- A . flatten ( order = 'C' ),
124+ * _dense_to_csr_args ( A . reshape ( m , n ) ),
67125 m ,
68126 n ,
69127 )
70128 elif right_arg .is_constant ():
71129 A = right_arg .value
130+ if not sparse .issparse (A ):
131+ A = np .asarray (A , dtype = np .float64 )
132+
133+ # For right matmul f(x) @ A: if A is 1D (n,), treat as column vector (n, 1)
134+ # C engine produces (d1, 1), but CVXPY expects (1, d1) for 1D results,
135+ # so we reshape after.
136+ is_1d = (A .ndim == 1 )
137+ if is_1d :
138+ A = A .reshape (- 1 , 1 )
72139
73140 if sparse .issparse (A ):
74141 if not isinstance (A , sparse .csr_matrix ):
75142 A = sparse .csr_matrix (A )
76143
77- return _diffengine .make_sparse_right_matmul (
144+ result = _diffengine .make_sparse_right_matmul (
78145 children [0 ],
79146 A .data .astype (np .float64 , copy = False ),
80147 A .indices .astype (np .int32 , copy = False ),
@@ -83,20 +150,38 @@ def _convert_matmul(expr, children):
83150 A .shape [1 ],
84151 )
85152 else :
86- m , n = normalize_shape (A .shape )
87- return _diffengine .make_dense_right_matmul (
153+ A = np .asarray (A , dtype = np .float64 )
154+ m , n = A .shape
155+ result = _diffengine .make_sparse_right_matmul (
88156 children [0 ],
89- A . flatten ( order = 'C' ),
157+ * _dense_to_csr_args ( A ),
90158 m ,
91159 n ,
92160 )
161+
162+ # Reshape (d1, 1) -> (1, d1) to match CVXPY's convention for 1D results
163+ if is_1d :
164+ d1 , _ = _diffengine .get_expr_dimensions (result )
165+ result = _diffengine .make_reshape (result , 1 , d1 )
166+ return result
93167 else :
94168 return _diffengine .make_matmul (children [0 ], children [1 ])
95169
96170def _convert_hstack (expr , children ):
97171 """Convert horizontal stack (hstack) of expressions."""
98172 return _diffengine .make_hstack (children )
99173
174+
175+ def _convert_vstack (expr , children ):
176+ """Convert vertical stack (vstack) via transpose(hstack(transpose(args))).
177+
178+ Reuses existing hstack and transpose — matches the approach in
179+ SparseDiffEngine PR #52.
180+ """
181+ transposed = [_diffengine .make_transpose (c ) for c in children ]
182+ hstacked = _diffengine .make_hstack (transposed )
183+ return _diffengine .make_transpose (hstacked )
184+
100185def _convert_multiply (expr , children ):
101186 """Convert multiplication based on argument types."""
102187 left_arg , right_arg = expr .args
@@ -288,6 +373,61 @@ def _convert_diag_vec(expr, children):
288373 raise NotImplementedError ("diag_vec with k != 0 not supported in diff engine" )
289374 return _diffengine .make_diag_vec (children [0 ])
290375
376+
377+ def _convert_kron (expr , children ):
378+ """Convert kron(C, X) or kron(X, C) where one argument is constant.
379+
380+ Uses native kron_left node for kron(C, X) — computes Jacobian via index
381+ remapping without materializing the full kron coefficient matrix.
382+ Falls back to left_matmul approach for kron(X, C).
383+ """
384+ left_arg , right_arg = expr .args
385+
386+ if left_arg .is_constant ():
387+ C = np .asarray (left_arg .value , dtype = np .float64 )
388+ child = children [1 ]
389+ p , q = normalize_shape (right_arg .shape )
390+
391+ # Reshape child to (p, q) for the C engine
392+ child_pq = _diffengine .make_reshape (child , p , q )
393+
394+ C_csr = sparse .csr_matrix (C )
395+ result = _diffengine .make_kron_left (
396+ child_pq ,
397+ C_csr .data .astype (np .float64 , copy = False ),
398+ C_csr .indices .astype (np .int32 , copy = False ),
399+ C_csr .indptr .astype (np .int32 , copy = False ),
400+ C_csr .shape [0 ], C_csr .shape [1 ], p , q ,
401+ )
402+
403+ # Reshape to CVXPY's expected output shape
404+ d1 , d2 = normalize_shape (expr .shape )
405+ return _diffengine .make_reshape (result , d1 , d2 )
406+ else :
407+ # kron(X, C): fall back to left_matmul approach
408+ C = np .asarray (right_arg .value , dtype = np .float64 )
409+ child = children [0 ]
410+ mx , nx = normalize_shape (left_arg .shape )
411+ p , q = C .shape
412+ m , n = mx , nx
413+ M = _build_kron_right_csr (C , mx , nx )
414+
415+ child_size = M .shape [1 ]
416+ out_size = M .shape [0 ]
417+
418+ child_flat = _diffengine .make_reshape (child , 1 , child_size )
419+ result = _diffengine .make_sparse_left_matmul (
420+ child_flat ,
421+ M .data .astype (np .float64 , copy = False ),
422+ M .indices .astype (np .int32 , copy = False ),
423+ M .indptr .astype (np .int32 , copy = False ),
424+ M .shape [0 ],
425+ M .shape [1 ],
426+ )
427+
428+ d1 , d2 = normalize_shape (expr .shape )
429+ return _diffengine .make_reshape (result , d1 , d2 )
430+
291431# Mapping from CVXPY atom names to C diff engine functions
292432# Converters receive (expr, children) where expr is the CVXPY expression
293433ATOM_CONVERTERS = {
@@ -334,11 +474,14 @@ def _convert_diag_vec(expr, children):
334474 # Reductions returning scalar
335475 "Prod" : _convert_prod ,
336476 "transpose" : _convert_transpose ,
337- # Horizontal stack
477+ # Horizontal / vertical stack
338478 "Hstack" : _convert_hstack ,
479+ "Vstack" : _convert_vstack ,
339480 "Trace" : _convert_trace ,
340481 # Diagonal
341482 "diag_vec" : _convert_diag_vec ,
483+ # Kronecker product
484+ "kron" : _convert_kron ,
342485}
343486
344487
0 commit comments