Sleipnir C++ API
Loading...
Searching...
No Matches
sqp.hpp
1// Copyright (c) Sleipnir contributors
2
3#pragma once
4
5#include <algorithm>
6#include <chrono>
7#include <cmath>
8#include <functional>
9#include <limits>
10#include <optional>
11#include <span>
12
13#include <Eigen/Core>
14#include <Eigen/SparseCore>
15#include <gch/small_vector.hpp>
16
17#include "sleipnir/optimization/solver/exit_status.hpp"
18#include "sleipnir/optimization/solver/iteration_info.hpp"
19#include "sleipnir/optimization/solver/options.hpp"
20#include "sleipnir/optimization/solver/sqp_matrix_callbacks.hpp"
21#include "sleipnir/optimization/solver/util/all_finite.hpp"
22#include "sleipnir/optimization/solver/util/append_as_triplets.hpp"
23#include "sleipnir/optimization/solver/util/constraint_violation.hpp"
24#include "sleipnir/optimization/solver/util/feasibility_restoration.hpp"
25#include "sleipnir/optimization/solver/util/feasibility_restoration_cycle_detector.hpp"
26#include "sleipnir/optimization/solver/util/filter.hpp"
27#include "sleipnir/optimization/solver/util/kkt_error.hpp"
28#include "sleipnir/optimization/solver/util/kkt_solver.hpp"
29#include "sleipnir/optimization/solver/util/time_trace.hpp"
30#include "sleipnir/util/assert.hpp"
31#include "sleipnir/util/print_diagnostics.hpp"
32#include "sleipnir/util/scope_exit.hpp"
33#include "sleipnir/util/symbol_exports.hpp"
34#include "sleipnir/util/time_tracer.hpp"
35
36// See docs/algorithms.md#Works_cited for citation definitions.
37
38namespace slp {
39
60template <typename Scalar>
61ExitStatus sqp(const SQPMatrixCallbacks<Scalar>& matrix_callbacks,
62 std::span<std::function<bool(const IterationInfo<Scalar>& info)>>
63 iteration_callbacks,
64 const Options<Scalar>& options, Eigen::VectorX<Scalar>& x) {
65 using DenseVector = Eigen::VectorX<Scalar>;
66
67 DenseVector y = DenseVector::Zero(matrix_callbacks.num_equality_constraints);
68
69 return sqp(matrix_callbacks, iteration_callbacks, options, x, y);
70}
71
94template <typename Scalar>
95ExitStatus sqp(const SQPMatrixCallbacks<Scalar>& matrix_callbacks,
96 std::span<std::function<bool(const IterationInfo<Scalar>& info)>>
97 iteration_callbacks,
98 const Options<Scalar>& options, Eigen::VectorX<Scalar>& x,
99 Eigen::VectorX<Scalar>& y) {
100 using DenseVector = Eigen::VectorX<Scalar>;
101 using SparseMatrix = Eigen::SparseMatrix<Scalar>;
102 using SparseVector = Eigen::SparseVector<Scalar>;
103
105 struct Step {
107 DenseVector p_x;
109 DenseVector p_y;
110
114 Scalar primal_inf_norm() const {
115 return p_x.template lpNorm<Eigen::Infinity>();
116 }
117
121 Scalar dual_inf_norm() const {
122 return p_y.template lpNorm<Eigen::Infinity>();
123 }
124
131 Scalar tiny_step_size(const DenseVector& x) const {
132 // A primal step αpˣ is negligible if
133 //
134 // ‖αpˣ/(1 + |x|)‖_∞ < 10ε
135 //
136 // where the absolute value and division are elementwise. Since α > 0, it
137 // factors out of the norm.
138 //
139 // α‖pˣ/(1 + |x|)‖_∞ < 10ε
140 //
141 // Dividing both sides by the norm gives
142 //
143 // α < 10ε / ‖pˣ/(1 + |x|)‖_∞
144 return Scalar(10) * std::numeric_limits<Scalar>::epsilon() /
145 (p_x.array().abs() / (Scalar(1) + x.array().abs()))
146 .matrix()
147 .template lpNorm<Eigen::Infinity>();
148 }
149 };
150
151 using std::isfinite;
152
153 const auto solve_start_time = std::chrono::steady_clock::now();
154
155 using enum SolveTimeTracerID;
156
157 int iterations = 0;
158
159 // Prints final solver diagnostics when the solver exits
160 SolveDiagnostics<sqp_time_trace> diagnostics{options.diagnostics,
161 options.diagnostics};
162
163 // Set up traced matrix callbacks if diagnostics are enabled
164#ifndef SLEIPNIR_DISABLE_DIAGNOSTICS
165 std::optional<SQPMatrixCallbacks<Scalar>> traced_matrices;
166 if (options.diagnostics) {
167 traced_matrices.emplace(
168 matrix_callbacks.num_decision_variables,
169 matrix_callbacks.num_equality_constraints,
170 traced(diagnostics[COST], matrix_callbacks.f),
171 traced(diagnostics[GRADIENT], matrix_callbacks.g),
172 traced(diagnostics[HESSIAN], matrix_callbacks.H),
173 traced(diagnostics[HESSIAN_CONSTRAINTS], matrix_callbacks.H_c),
174 traced(diagnostics[EQUALITY_CONSTRAINTS], matrix_callbacks.c_e),
175 traced(diagnostics[EQUALITY_JACOBIAN], matrix_callbacks.A_e),
176 matrix_callbacks.scaling);
177 }
178 const auto& matrices = traced_matrices ? *traced_matrices : matrix_callbacks;
179#else
180 const auto& matrices = matrix_callbacks;
181#endif
182
183 ScopedTimeTracer setup_tracer{diagnostics[SETUP]};
184
185 Scalar f = matrices.f(x);
186 SparseVector g = matrices.g(x);
187 SparseMatrix H = matrices.H(x, y);
188 DenseVector c_e = matrices.c_e(x);
189 SparseMatrix A_e = matrices.A_e(x);
190
191 // Ensure matrix callback dimensions are consistent
192 slp_assert(g.rows() == matrices.num_decision_variables);
193 slp_assert(H.rows() == matrices.num_decision_variables);
194 slp_assert(H.cols() == matrices.num_decision_variables);
195 slp_assert(c_e.rows() == matrices.num_equality_constraints);
196 slp_assert(A_e.rows() == matrices.num_equality_constraints);
197 slp_assert(A_e.cols() == matrices.num_decision_variables);
198
199 DenseVector trial_x;
200 DenseVector trial_y;
201
202 Scalar trial_f;
203 DenseVector trial_c_e;
204
205 // Check for overconstrained problem
206 if (matrices.num_equality_constraints > matrices.num_decision_variables) {
207 if (options.diagnostics) {
208 print_too_few_dofs_error(c_e);
209 }
210
211 return ExitStatus::TOO_FEW_DOFS;
212 }
213
214 // Check whether initial guess has finite cost, constraints, and derivatives
215 if (!isfinite(f) || !all_finite(g) || !all_finite(H) || !c_e.allFinite() ||
216 !all_finite(A_e)) {
217 return ExitStatus::NONFINITE_INITIAL_GUESS;
218 }
219
220 Filter<Scalar> filter{constraint_violation(c_e)};
221
222 // Kept outside the loop so its storage can be reused
223 gch::small_vector<Eigen::Triplet<Scalar>> triplets;
224
225 const int lhs_rows =
226 matrices.num_decision_variables + matrices.num_equality_constraints;
227 KKTSolver<Scalar> solver{
228 // Use sparse solver if lower triangle fills < 25% of system
229 H.nonZeros() + A_e.nonZeros() < 0.25 * lhs_rows * lhs_rows,
230 matrices.num_decision_variables, matrices.num_equality_constraints};
231
232 // Step size reduction factor
233 constexpr Scalar α_reduction_factor(0.5);
234
235 int full_step_rejected_counter = 0;
236
237 // The smallest KKT error of a step accepted by the KKT error reduction
238 // fallback. Each fallback step must improve on it, so finitely many fallback
239 // steps are taken before the solver converges.
240 Scalar fallback_kkt_error = std::numeric_limits<Scalar>::infinity();
241
242 StallDetector<Scalar> stall_detector;
243
244 FeasibilityRestorationCycleDetector<Scalar>
245 feasibility_restoration_cycle_detector;
246
247 // Error
248 Scalar E_0 = unscaled_kkt_error<Scalar, KKTErrorType::INF_NORM_SCALED>(
249 matrices.scaling, g, A_e, c_e, y, options.tolerance);
250
251 setup_tracer.stop();
252
253 // Prints initial iterate diagnostics now and closes the iterate diagnostics
254 // table when the solver exits
255 IterateDiagnosticsTable table{
256 options.diagnostics, E_0, f,
257 constraint_violation(c_e), Scalar(0), Scalar(0)};
258
259 while (E_0 > options.tolerance) {
260 ScopedTimeTracer inner_iter_tracer{diagnostics[ITERATION]};
261
262 // Check for diverging iterates
263 if (x.template lpNorm<Eigen::Infinity>() > Scalar(1e10) || !x.allFinite()) {
264 return ExitStatus::DIVERGING_ITERATES;
265 }
266
267 PhaseTimeTracer phase_tracer{diagnostics[CALLBACKS]};
268
269 // Call iteration callbacks
270 for (const auto& callback : iteration_callbacks) {
271 if (callback({iterations, x, {}, y, {}, g, H, A_e, {}})) {
272 return ExitStatus::CALLBACK_REQUESTED_STOP;
273 }
274 }
275
276 phase_tracer.next(diagnostics[KKT_MATRIX_BUILD]);
277
278 // lhs = [H Aₑᵀ]
279 // [Aₑ 0 ]
280 //
281 // Don't assign upper triangle because solver only uses lower triangle.
282 triplets.clear();
283 triplets.reserve(H.nonZeros() + A_e.nonZeros());
284 append_as_triplets(triplets, 0, 0, {H, A_e});
285 SparseMatrix lhs(
286 matrices.num_decision_variables + matrices.num_equality_constraints,
287 matrices.num_decision_variables + matrices.num_equality_constraints);
288 lhs.setFromSortedTriplets(triplets.begin(), triplets.end());
289
290 // rhs = −[∇f − Aₑᵀy]
291 // [ cₑ ]
292 DenseVector rhs{x.rows() + y.rows()};
293 rhs.segment(0, x.rows()) = -g + A_e.transpose() * y;
294 rhs.segment(x.rows(), y.rows()) = -c_e;
295
296 phase_tracer.next(diagnostics[KKT_MATRIX_DECOMP]);
297
298 Step step;
299 constexpr Scalar α_max(1);
300 Scalar α(1);
301 bool call_feasibility_restoration = false;
302
303 // Solve the Newton-KKT system
304 //
305 // [H Aₑᵀ][ pˣ] = −[∇f − Aₑᵀy]
306 // [Aₑ 0 ][−pʸ] [ cₑ ]
307 if (solver.compute(lhs).info() != Eigen::Success) [[unlikely]] {
308 return ExitStatus::FACTORIZATION_FAILED;
309 }
310
311 phase_tracer.next(diagnostics[KKT_SYSTEM_SOLVE]);
312
313 auto compute_step = [&](Step& step) {
314 // p = [ pˣ]
315 // [−pʸ]
316 DenseVector p = solver.solve(rhs);
317 step.p_x = p.segment(0, x.rows());
318 step.p_y = -p.segment(x.rows(), y.rows());
319 };
320 compute_step(step);
321
322 phase_tracer.next(diagnostics[LINE_SEARCH]);
323
324 α = α_max;
325
326 const FilterEntry<Scalar> current_entry{f, c_e};
327 const Scalar D_ϕ = g.transpose() * step.p_x;
328
329 const Scalar α_min = filter.min_step_size(current_entry, D_ϕ);
330
331 // α_min can be zero (e.g., when the constraint violation is zero), so also
332 // end the line search when the step becomes negligible relative to the
333 // iterate. This bounds the number of step size reductions.
334 const Scalar α_tiny = step.tiny_step_size(x);
335
336 // Loop until a step is accepted
337 while (1) {
338 trial_x = x + α * step.p_x;
339 trial_y = y + α * step.p_y;
340
341 trial_f = matrices.f(trial_x);
342 trial_c_e = matrices.c_e(trial_x);
343
344 // If f(xₖ + αpₖˣ) or cₑ(xₖ + αpₖˣ) aren't finite, reduce step size
345 // immediately
346 if (!isfinite(trial_f) || !trial_c_e.allFinite()) {
347 // Reduce step size
348 α *= α_reduction_factor;
349
350 if (α < α_min || α < α_tiny) {
351 call_feasibility_restoration = true;
352 break;
353 }
354 continue;
355 }
356
357 // Check whether filter accepts trial iterate
358 FilterEntry trial_entry{trial_f, trial_c_e};
359 if (filter.try_add(current_entry, trial_entry, D_ϕ, α)) {
360 // Accept step
361 break;
362 }
363
364 Scalar prev_constraint_violation = constraint_violation(c_e);
365 Scalar next_constraint_violation = constraint_violation(trial_c_e);
366
367 // Second-order corrections
368 //
369 // If first trial point was rejected and constraint violation stayed the
370 // same or went up, apply second-order corrections
371 if (α == α_max &&
372 next_constraint_violation >= prev_constraint_violation) {
373 // Apply second-order corrections. See section 2.4 of [2].
374 auto soc_step = step;
375
376 Scalar α_soc = α;
377 DenseVector c_e_soc = c_e;
378
379 Scalar soc_constraint_violation = next_constraint_violation;
380
381 bool step_acceptable = false;
382 for (int soc_iteration = 0; soc_iteration < 5 && !step_acceptable;
383 ++soc_iteration) {
384 ScopedTimeTracer soc_tracer{diagnostics[SOC]};
385
386 scope_exit soc_exit{[&] {
387 soc_tracer.stop();
388
389 if (options.diagnostics && step_acceptable) {
390 print_iterate_diagnostics(
391 iterations, IterationType::SECOND_ORDER_CORRECTION,
392 soc_tracer.current_duration(),
393 kkt_error<Scalar, KKTErrorType::INF_NORM_SCALED>(
394 g, A_e, trial_c_e, trial_y, options.tolerance),
395 trial_f, constraint_violation(trial_c_e), Scalar(0),
396 Scalar(0), solver.hessian_regularization(),
397 solver.constraint_jacobian_regularization(),
398 soc_step.primal_inf_norm(), soc_step.dual_inf_norm(), α_soc,
399 Scalar(1), α_reduction_factor, Scalar(1));
400 }
401 }};
402
403 // Rebuild Newton-KKT rhs with updated constraint values.
404 //
405 // rhs = −[∇f − Aₑᵀy]
406 // [ cₑˢᵒᶜ ]
407 //
408 // where cₑˢᵒᶜ = αc(xₖ) + c(xₖ + αpₖˣ)
409 c_e_soc = α_soc * c_e_soc + trial_c_e;
410 rhs.bottomRows(y.rows()) = -c_e_soc;
411
412 // Solve the Newton-KKT system
413 compute_step(soc_step);
414
415 trial_x = x + α_soc * soc_step.p_x;
416 trial_y = y + α_soc * soc_step.p_y;
417
418 trial_f = matrices.f(trial_x);
419 trial_c_e = matrices.c_e(trial_x);
420
421 // Check whether the filter accepts trial iterate
422 FilterEntry trial_entry{trial_f, trial_c_e};
423 if (filter.try_add(current_entry, trial_entry, D_ϕ, α)) {
424 step = soc_step;
425 α = α_soc;
426 step_acceptable = true;
427 break;
428 }
429
430 // Constraint violation scale factor for second-order corrections
431 constexpr Scalar κ_soc(0.99);
432
433 // If constraint violation hasn't been sufficiently reduced, stop
434 // making second-order corrections
435 next_constraint_violation = constraint_violation(trial_c_e);
436 if (next_constraint_violation > κ_soc * soc_constraint_violation) {
437 break;
438 }
439
440 soc_constraint_violation = next_constraint_violation;
441 }
442
443 if (step_acceptable) {
444 // Accept step
445 break;
446 }
447 }
448
449 // If we got here and α is the full step, the full step was rejected.
450 // Increment the full-step rejected counter to keep track of how many full
451 // steps have been rejected in a row.
452 if (α == α_max) {
453 ++full_step_rejected_counter;
454 }
455
456 // If the full step was rejected enough times in a row, reset the filter
457 // if it may be impeding progress
458 if (full_step_rejected_counter >= 4 && filter.try_reset(current_entry)) {
459 continue;
460 }
461
462 // Reduce step size
463 α *= α_reduction_factor;
464
465 // If step size hit a minimum or the step became negligible, check if the
466 // KKT error was reduced. If it wasn't, invoke feasibility restoration.
467 if (α < α_min || α < α_tiny) {
468 Scalar current_kkt_error = kkt_error<Scalar, KKTErrorType::ONE_NORM>(
469 g, A_e, c_e, y, options.tolerance);
470
471 trial_x = x + α_max * step.p_x;
472 trial_y = y + α_max * step.p_y;
473
474 trial_f = matrices.f(trial_x);
475 trial_c_e = matrices.c_e(trial_x);
476
477 Scalar next_kkt_error = kkt_error<Scalar, KKTErrorType::ONE_NORM>(
478 matrices.g(trial_x), matrices.A_e(trial_x), trial_c_e, trial_y,
479 options.tolerance);
480
481 // If the step using αᵐᵃˣ reduced the KKT error below the current
482 // iterate's and every previous fallback step's, accept it anyway
483 if (next_kkt_error <= Scalar(0.999) * std::min(current_kkt_error,
484 fallback_kkt_error) &&
485 constraint_violation(trial_c_e) <=
486 filter.max_constraint_violation) {
487 // Accept step
488 fallback_kkt_error = next_kkt_error;
489 α = α_max;
490 break;
491 }
492
493 call_feasibility_restoration = true;
494 break;
495 }
496 }
497
498 phase_tracer.stop();
499
500 // If solver stalled, invoke feasibility restoration
501 if (!call_feasibility_restoration) {
502 FilterEntry trial_entry{trial_f, trial_c_e};
503 if (stall_detector.update(current_entry, trial_entry, α,
504 filter.min_constraint_violation)) {
505 call_feasibility_restoration = true;
506 }
507 }
508
509 if (call_feasibility_restoration) {
510 ScopedTimeTracer feasibility_restoration_tracer{
511 diagnostics[FEASIBILITY_RESTORATION]};
512
513 FilterEntry initial_entry{matrices.f(x), c_e};
514
515 // Augment the filter with the current iterate so the normal phase can't
516 // return to it
517 filter.add(initial_entry);
518
519 feasibility_restoration_cycle_detector.record_entry(
520 initial_entry.constraint_violation);
521 const bool run_feasibility_restoration_to_convergence =
522 feasibility_restoration_cycle_detector.is_cycling();
523
524 // Feasibility restoration phase
525 gch::small_vector<std::function<bool(const IterationInfo<Scalar>& info)>>
526 callbacks;
527 for (auto& callback : iteration_callbacks) {
528 callbacks.emplace_back(callback);
529 }
530 callbacks.emplace_back([&](const IterationInfo<Scalar>& info) {
531 if (run_feasibility_restoration_to_convergence) {
532 return false;
533 }
534
535 DenseVector trial_x =
536 info.x.segment(0, matrices.num_decision_variables);
537
538 DenseVector trial_c_e = matrices.c_e(trial_x);
539
540 FilterEntry trial_entry{matrices.f(trial_x), trial_c_e};
541
542 // If the current iterate sufficiently reduces constraint violation and
543 // is accepted by the normal filter, stop feasibility restoration
544 return trial_entry.constraint_violation <
545 Scalar(0.9) * initial_entry.constraint_violation &&
546 filter.is_acceptable(trial_entry);
547 });
548 auto status = feasibility_restoration<Scalar>(matrices, callbacks,
549 options, x, y, iterations);
550
551 if (status != ExitStatus::SUCCESS) {
552 // Report failure
553 return status;
554 }
555
556 f = matrices.f(x);
557 c_e = matrices.c_e(x);
558
559 // Resuming from an iterate the filter rejects would break the filter's
560 // convergence guarantee, and continuing feasibility restoration from its
561 // converged point would return that point again, so report failure.
562 //
563 // The returned iterate can be rejected because feasibility restoration
564 // stops at its own convergence tolerance instead of driving constraint
565 // violation to zero, so the filter can still hold entries with lower cost
566 // and constraint violation.
567 if (!filter.is_acceptable(FilterEntry{f, c_e})) {
568 return ExitStatus::FEASIBILITY_RESTORATION_FAILED;
569 }
570 } else {
571 // If full step was accepted, reset full-step rejected counter
572 if (α == α_max) {
573 full_step_rejected_counter = 0;
574 }
575
576 // Update iterates
577 x = trial_x;
578 y = trial_y;
579
580 f = trial_f;
581 c_e = trial_c_e;
582 }
583
584 // Update autodiff for Jacobians and Hessian
585 A_e = matrices.A_e(x);
586 g = matrices.g(x);
587 H = matrices.H(x, y);
588
589 // Update the error
590 E_0 = unscaled_kkt_error<Scalar, KKTErrorType::INF_NORM_SCALED>(
591 matrices.scaling, g, A_e, c_e, y, options.tolerance);
592
593 inner_iter_tracer.stop();
594
595 if (options.diagnostics) {
596 print_iterate_diagnostics(iterations, IterationType::NORMAL,
597 inner_iter_tracer.current_duration(), E_0, f,
598 constraint_violation(c_e), Scalar(0), Scalar(0),
599 solver.hessian_regularization(),
600 solver.constraint_jacobian_regularization(),
601 step.primal_inf_norm(), step.dual_inf_norm(), α,
602 α_max, α_reduction_factor, Scalar(1));
603 }
604
605 ++iterations;
606
607 // Check for max iterations
608 if (iterations >= options.max_iterations) {
609 return ExitStatus::MAX_ITERATIONS_EXCEEDED;
610 }
611
612 // Check for max wall clock time
613 if (std::chrono::steady_clock::now() - solve_start_time > options.timeout) {
614 return ExitStatus::TIMEOUT;
615 }
616 }
617
618 if (!isfinite(E_0)) {
619 return ExitStatus::DIVERGING_ITERATES;
620 } else {
621 return ExitStatus::SUCCESS;
622 }
623}
624
625extern template SLEIPNIR_DLLEXPORT ExitStatus
626sqp(const SQPMatrixCallbacks<double>& matrix_callbacks,
627 std::span<std::function<bool(const IterationInfo<double>& info)>>
628 iteration_callbacks,
629 const Options<double>& options, Eigen::VectorX<double>& x);
630
631} // namespace slp