Coverage for src/cvx/quadprog/_structure.py: 100%

25 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-25 06:10 +0000

1"""Exploiting the shape of the constraint matrix. 

2 

3A bound constraint is a column holding a single nonzero, and a box is nothing 

4else. Detecting that turns three per-iteration products into indexing, and 

5choosing the slack product by size and density decides the rest. 

6""" 

7 

8# G, C, R and J are the names used in Goldfarb & Idnani (1983) and in the 

9# reference implementation's public signature `solve_qp(G, a, C, b, meq)`. 

10# Lowercasing them would obscure the correspondence to the paper, so the 

11# pep8-naming rules are waived here, as they are in _solve.py. 

12# ruff: noqa: N803, TRY003 

13 

14from collections.abc import Callable 

15 

16import numpy as np 

17import scipy.sparse 

18 

19# Size below which the slack product is dense even when C is sparse enough to 

20# favour CSR on flops alone. scipy's CSR matvec is compiled, but reaching it 

21# costs some twenty interpreter-level calls per product -- isinstance and abc 

22# checks, sputils lookups, allocating the result -- against the single matmul a 

23# dense product takes. That overhead is roughly fixed while the dense product 

24# grows as n * m, so below some size it is not worth paying however sparse the 

25# matrix is. Measured end to end on budget-plus-bounds problems, dense wins by 

26# 1.34x at n = 10 and 1.10x at n = 100, ties at n = 400 (n * m = 320_400), and 

27# loses from n = 450 (n * m = 405_450) on, reaching 0.62x by n = 1200. 

28_SPARSE_MIN_WORK = 350_000 

29 

30# Reciprocal of the density above which the dense product wins outright, whatever 

31# the size: CSR is used only when nnz * _SPARSE_DENSITY_FACTOR <= n * m. Measured 

32# per product over n * m from 80_000 to 4_500_000, CSR wins at and below 2% 

33# density at every size and loses at 5% for all but the largest, so the crossover 

34# sits near 3-4% and moves little with size -- the two costs are both linear, in 

35# nnz and in n * m respectively, so their ratio is what decides. 

36_SPARSE_DENSITY_FACTOR = 25 

37 

38 

39def _default_constraints( 

40 G: np.ndarray, C: np.ndarray | None, b: np.ndarray | None, meq: int 

41) -> tuple[np.ndarray, np.ndarray, int]: 

42 """Fill in the unconstrained problem and coerce the constraint arrays. 

43 

44 Omitting both ``C`` and ``b`` asks for the unconstrained minimum. Rather 

45 than branch on that everywhere below, it is expressed as a single constraint 

46 ``0 >= -1``, which no ``x`` can violate: the solver then runs its ordinary 

47 path and terminates on the first iteration. Supplying exactly one of the two 

48 is an error rather than a shape crash further in. 

49 

50 Args: 

51 G: ``(n, n)`` quadratic term, used only for its size. 

52 C: ``(n, m)`` constraint matrix, or None. 

53 b: ``(m,)`` right-hand side, or None. 

54 meq: Number of leading constraints to treat as equalities. 

55 

56 Returns: 

57 ``C``, ``b`` as float64 arrays and the ``meq`` that goes with them -- 

58 forced to 0 when the unconstrained placeholder is substituted, since 

59 that constraint must not be read as an equality. 

60 

61 Raises: 

62 ValueError: If exactly one of ``C`` and ``b`` is given. 

63 """ 

64 if C is None and b is None: 

65 return np.zeros((len(G), 1)), -np.ones(1), 0 

66 if C is None or b is None: 

67 raise ValueError("C and b must be given together") 

68 return np.asarray(C, dtype=np.float64), np.asarray(b, dtype=np.float64), meq 

69 

70 

71def _analyse_constraints(C: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: 

72 """Locate the columns of ``C`` that hold a single scaled unit vector. 

73 

74 Bound constraints -- the overwhelmingly common shape, and what 

75 ``C = [I, -I]`` is -- make every column one nonzero. Recognising that lets 

76 the products against a constraint normal become scalar indexing rather than 

77 length-``n`` reductions. 

78 

79 Args: 

80 C: ``(n, m)`` constraint matrix, one column per constraint. 

81 

82 Returns: 

83 A boolean mask of the single-nonzero columns, the row index of that 

84 nonzero per column, and its value. Entries of the latter two are 

85 meaningless where the mask is False. 

86 """ 

87 nonzero = C != 0.0 

88 single = nonzero.sum(axis=0) == 1 

89 # argmax gives the first nonzero row, which for a single-nonzero column is 

90 # the only one. Columns failing the mask still index safely, just uselessly. 

91 row = np.argmax(nonzero, axis=0) 

92 return single, row, C[row, np.arange(C.shape[1])] 

93 

94 

95def _slack_evaluator( 

96 C: np.ndarray, single: np.ndarray, srow: np.ndarray, sval: np.ndarray 

97) -> Callable[[np.ndarray], np.ndarray]: 

98 """Return the cheapest available way to evaluate ``C.T @ x``. 

99 

100 This runs once per outer iteration over all ``m`` constraints, so on a 

101 box-constrained problem -- where ``m = 2n`` -- the dense product is the 

102 single largest cost in the solver. Three strategies, in preference order: 

103 

104 * every column a single nonzero: one gather, ``O(m)``; 

105 * big enough, and sparse enough, to pay for the bookkeeping: a CSR product, 

106 ``O(nnz)``; 

107 * otherwise the dense product, transposed once here rather than per call. 

108 

109 The CSR branch needs both tests. Density decides which product does fewer 

110 flops, and it has to be genuinely low -- see :data:`_SPARSE_DENSITY_FACTOR`, 

111 which is far stricter than the compiled matvec's speed alone would suggest. 

112 But below :data:`_SPARSE_MIN_WORK` the flops are not what the product costs: 

113 reaching scipy's matvec takes some twenty interpreter-level calls where a 

114 dense product takes one, so a small enough problem is served better densely 

115 however sparse it is. 

116 

117 Args: 

118 C: ``(n, m)`` constraint matrix. 

119 single: Mask of the columns holding exactly one nonzero. 

120 srow: Row index of that nonzero per column, from 

121 :func:`_analyse_constraints`. 

122 sval: Value of that nonzero per column, from the same place. 

123 

124 Returns: 

125 A callable mapping ``x`` to ``C.T @ x``. Every branch returns a freshly 

126 allocated array, which callers rely on: both this module's callers go on 

127 to force entries of it to zero in place. 

128 """ 

129 n, m = C.shape 

130 

131 if m and single.all(): 

132 # `srow` and `sval` are taken from the caller rather than rebuilt here. 

133 # This used to recompute both, plus the n x m boolean `C != 0.0` they come 

134 # from, on the same C the caller had just analysed -- two extra O(n * m) 

135 # passes and a discarded n x m temporary per solve, measured at 0.70 ms of 

136 # a 14.5 ms solve at n = 800 and 2.21 ms at n = 1400 (#108). 

137 return lambda x: sval * x[srow] 

138 

139 # The size test is first because it is free, where count_nonzero is O(n * m). 

140 if n * m >= _SPARSE_MIN_WORK and np.count_nonzero(C) * _SPARSE_DENSITY_FACTOR <= n * m: 

141 ct = scipy.sparse.csr_matrix(C.T) 

142 return lambda x: ct @ x 

143 

144 dense = np.ascontiguousarray(C.T) 

145 return lambda x: dense @ x