LINE Solver (C++)
Templated C++ port of the LINE queueing solver
Loading...
Searching...
No Matches
line_opt_solver.h
Go to the documentation of this file.
1/*
2 * Copyright (c) 2012-2026, QORE Lab, Imperial College London
3 * All rights reserved.
4 */
5#ifndef LINE_OPT_LINE_OPT_SOLVER_H
6#define LINE_OPT_LINE_OPT_SOLVER_H
7
8/**
9 * @file
10 * @ingroup line_opt
11 * The optimizer: search the variable space for the best feasible point.
12 *
13 * `LineOptSolver` drives an `OptimizationProblem` with the strategy named by
14 * `LineOptSolverOptions::optimizer` -- `evolution` (differential evolution, the
15 * default, over the normalized [0,1] box) or a gradient method that uses the
16 * derivatives of `sensitivity.h` where they exist and finite differences where
17 * they do not. Constraints enter through the penalty of
18 * `Objective::evaluate_with_penalty`.
19 *
20 * `LineOptSolverOptions` carries the usual DE controls (`strategy`, `popsize`,
21 * the `mutation_low`/`mutation_high` dither, `recombination`, `tolerance`,
22 * `max_iterations`, `seed`), the stopping `time_limit`, and the finite-difference
23 * steps, which are separate for the flat and layered cases (`fd_step`,
24 * `fd_step_layered`) because an LQN demand and a service rate are not of
25 * comparable scale. `scenario_aggregation` says how a multi-scenario objective
26 * is combined -- `worst` by default, so a design is judged by its worst load
27 * rather than its average one. Every setter returns `*this`.
28 */
29
30#include <algorithm>
31#include <chrono>
32#include <cmath>
33#include <cstddef>
34#include <cstdint>
35#include <limits>
36#include <map>
37#include <optional>
38#include <set>
39#include <string>
40#include <utility>
41#include <vector>
42
44#include "line/opt/problem.h"
45#include "line/util/error.h"
46
47namespace line {
48namespace opt {
49
51 std::string strategy = "best1bin";
52 std::size_t popsize = 15;
53 double mutation_low = 0.5;
54 double mutation_high = 1.0;
55 double recombination = 0.7;
56 double tolerance = 0.01;
57 std::size_t max_iterations = 100;
58 double time_limit = 300.0;
59 std::optional<std::uint64_t> seed;
60 bool verbose = false;
61 double penalty_weight = 1e6;
62 std::string scenario_aggregation = "worst";
63 std::string optimizer = "evolution";
64 double fd_step = 1e-6;
65 double fd_step_layered = 1e-4;
66 std::size_t gradient_restarts = 4;
67 std::string lqn_gradient = "fd";
68 std::size_t fd_refresh = 5;
69 std::vector<std::string> frozen_layers;
70
71 LineOptSolverOptions& set_strategy(std::string value) {
72 strategy = std::move(value);
73 return *this;
74 }
75 LineOptSolverOptions& set_popsize(std::size_t value) {
76 popsize = value;
77 return *this;
78 }
79 LineOptSolverOptions& set_mutation(double low, double high) {
80 mutation_low = low;
81 mutation_high = high;
82 return *this;
83 }
85 recombination = value;
86 return *this;
87 }
89 tolerance = value;
90 return *this;
91 }
93 max_iterations = value;
94 return *this;
95 }
97 time_limit = value;
98 return *this;
99 }
100 LineOptSolverOptions& set_seed(std::uint64_t value) {
101 seed = value;
102 return *this;
103 }
105 seed.reset();
106 return *this;
107 }
109 verbose = value;
110 return *this;
111 }
113 penalty_weight = value;
114 return *this;
115 }
117 scenario_aggregation = std::move(value);
118 return *this;
119 }
121 optimizer = std::move(value);
122 return *this;
123 }
125 fd_step = value;
126 return *this;
127 }
129 fd_step_layered = value;
130 return *this;
131 }
133 gradient_restarts = value;
134 return *this;
135 }
137 if (value != "fd" && value != "partial_sens" && value != "partial_plus_fd")
138 throw InputError("lqn_gradient must be 'fd', 'partial_sens', or 'partial_plus_fd'");
139 lqn_gradient = std::move(value);
140 return *this;
141 }
143 fd_refresh = value;
144 return *this;
145 }
146 LineOptSolverOptions& set_frozen_layers(std::vector<std::string> value) {
147 frozen_layers = std::move(value);
148 return *this;
149 }
150};
151
153public:
156 : problem_(problem), options_(std::move(options)), solve_(std::move(solve)) {
157 const std::vector<std::string> errors = problem_.validate();
158 if (!errors.empty()) throw InputError("LineOptSolver: invalid optimization problem");
159 free_variables_ = problem_.variables();
160 fixed_variables_ = problem_.fixed_variables();
161 freeze_layers();
162 for (const auto& item : fixed_variables_)
163 fixed_values_[item.first->name()] = item.second;
164
165 evaluators_.emplace_back(problem_.model_variant(), free_variables_, fixed_variables_, solve_);
166 scenario_weights_.push_back(1.0);
167 for (const Scenario& scenario : problem_.scenarios()) {
168 evaluators_.emplace_back(scenario.model, free_variables_, fixed_variables_, solve_);
169 scenario_weights_.push_back(scenario.weight);
170 }
171 caches_.resize(evaluators_.size());
172 }
173
175 start_ = Clock::now();
176 iterations_ = 0;
177 best_value_ = std::numeric_limits<double>::infinity();
178 best_x_.clear();
179 convergence_history_.clear();
180 gradient_calls_ = 0;
181 lqn_sensitivity_cache_.clear();
182 resolved_seed_ = options_.seed.value_or(static_cast<std::uint64_t>(
183 std::chrono::duration_cast<std::chrono::nanoseconds>(
184 Clock::now().time_since_epoch()).count()) & UINT64_C(0x7fffffff));
185 for (auto& cache : caches_) cache.clear();
186
187 const std::vector<std::pair<double, double>> bounds = evaluators_[0].bounds();
188 if (bounds.empty()) return empty_result();
189 if (should_use_gradient()) return solve_gradient(bounds.size());
190 return solve_evolution(bounds.size());
191 }
192
193private:
194 using Clock = std::chrono::steady_clock;
195 struct TimeLimit {};
196
197 double elapsed() const {
198 return std::chrono::duration<double>(Clock::now() - start_).count();
199 }
200
201 VariableValues merge_values(const VariableValues& values) const {
202 VariableValues out = fixed_values_;
203 for (const auto& item : values) out[item.first] = item.second;
204 return out;
205 }
206
207 const EvaluationResult& evaluate(std::size_t scenario, const VariableValues& values) {
208 auto found = caches_[scenario].find(values);
209 if (found != caches_[scenario].end()) return found->second;
210 EvaluationResult result = evaluators_[scenario].evaluate(values);
211 return caches_[scenario].emplace(values, std::move(result)).first->second;
212 }
213
214 double aggregate_scenarios(const std::vector<double>& values) const {
215 if (values.size() == 1) return values[0];
216 if (options_.scenario_aggregation == "mean") {
217 double weighted = 0.0, weights = 0.0;
218 for (std::size_t i = 0; i < values.size(); ++i) {
219 weighted += scenario_weights_[i] * values[i];
220 weights += scenario_weights_[i];
221 }
222 return weighted / weights;
223 }
224 if (options_.scenario_aggregation != "worst")
225 throw InputError("LineOptSolver: unknown scenario aggregation '" +
226 options_.scenario_aggregation + "'");
227 return *std::max_element(values.begin(), values.end());
228 }
229
230 double objective_function(const std::vector<double>& x) {
231 if (elapsed() >= options_.time_limit) throw TimeLimit();
232 const VariableValues values = evaluators_[0].decode(x);
233 const VariableValues all_values = merge_values(values);
234 std::vector<double> scenario_values(evaluators_.size(), 0.0);
235 for (std::size_t i = 0; i < evaluators_.size(); ++i) {
236 const EvaluationResult& result = evaluate(i, values);
237 if (!result.feasible) return std::numeric_limits<double>::infinity();
238 double value = problem_.objective()->evaluate_with_penalty(
239 result, all_values, options_.penalty_weight);
240 for (const ConstraintPtr& constraint : problem_.constraints())
241 value += constraint->evaluate(result, all_values) * options_.penalty_weight;
242 scenario_values[i] = value;
243 }
244 const double total = aggregate_scenarios(scenario_values);
245 if (total < best_value_) {
246 best_value_ = total;
247 best_x_ = x;
248 }
249 return total;
250 }
251
252 OptimizationResult solve_evolution(std::size_t dimension) {
253 std::vector<double> low(dimension, 0.0), high(dimension, 1.0);
254 de::DifferentialEvolution engine(
255 [this](const std::vector<double>& x) { return objective_function(x); }, low, high,
256 options_.strategy, options_.popsize, options_.max_iterations,
257 options_.mutation_low, options_.mutation_high, options_.recombination,
258 options_.tolerance, resolved_seed_);
259 engine.set_callback([this](const std::vector<double>&, std::size_t nit) {
260 iterations_ = nit;
261 convergence_history_.push_back(best_value_);
262 return elapsed() >= options_.time_limit;
263 });
264
265 bool timed_out = false;
266 std::vector<double> x;
267 double value = std::numeric_limits<double>::infinity();
268 try {
269 const de::DifferentialEvolutionResult result = engine.solve();
270 if (!best_x_.empty() && best_value_ <= result.fun) {
271 x = best_x_;
272 value = best_value_;
273 } else {
274 x = result.x;
275 value = result.fun;
276 }
277 } catch (const TimeLimit&) {
278 timed_out = true;
279 if (best_x_.empty()) return empty_result();
280 x = best_x_;
281 value = best_value_;
282 }
283 OptimizationResult result = build_result(x, value);
284 if (timed_out) result.terminated_by = "time_limit";
285 return result;
286 }
287
288 bool all_continuous() const {
289 if (free_variables_.empty()) return false;
290 for (const VariablePtr& variable : free_variables_) {
291 const std::string type = variable->type();
292 if (type != "service_rate" && type != "routing" && type != "host_demand" &&
293 type != "think_time")
294 return false;
295 }
296 return true;
297 }
298
299 bool should_use_gradient() const {
300 if (options_.optimizer == "gradient") return true;
301 if (options_.optimizer == "evolution") return false;
302 if (options_.optimizer == "auto") return all_continuous();
303 throw InputError("LineOptSolver: unknown optimizer '" + options_.optimizer + "'");
304 }
305
306 std::vector<double> finite_difference_gradient(const std::vector<double>& x) {
307 const double h = evaluators_[0].is_layered() ? options_.fd_step_layered
308 : options_.fd_step;
309 std::vector<double> gradient(x.size(), 0.0);
310 std::optional<double> centre;
311 for (std::size_t i = 0; i < x.size(); ++i) {
312 std::vector<double> plus = x, minus = x;
313 plus[i] = std::min(1.0, x[i] + h);
314 minus[i] = std::max(0.0, x[i] - h);
315 const double fp = objective_function(plus);
316 const double fm = objective_function(minus);
317 if (std::isfinite(fp) && std::isfinite(fm) && plus[i] > minus[i]) {
318 gradient[i] = (fp - fm) / (plus[i] - minus[i]);
319 continue;
320 }
321 if (!centre) centre = objective_function(x);
322 if (std::isfinite(fp) && std::isfinite(*centre) && plus[i] > x[i])
323 gradient[i] = (fp - *centre) / (plus[i] - x[i]);
324 else if (std::isfinite(fm) && std::isfinite(*centre) && x[i] > minus[i])
325 gradient[i] = (*centre - fm) / (x[i] - minus[i]);
326 }
327 return gradient;
328 }
329
330 std::vector<double> projected_gradient_descent(std::vector<double> x) {
331 for (double& value : x) value = std::clamp(value, 0.0, 1.0);
332 double f = objective_function(x);
333 for (std::size_t iter = 0; iter < options_.max_iterations; ++iter) {
334 const std::vector<double> gradient = objective_gradient(x);
335 double norm2 = 0.0;
336 for (double value : gradient) norm2 += value * value;
337 if (std::sqrt(norm2) < 1e-9) break;
338 double step = 1.0;
339 bool improved = false;
340 for (std::size_t search = 0; search < 30; ++search) {
341 std::vector<double> next(x.size());
342 for (std::size_t i = 0; i < x.size(); ++i)
343 next[i] = std::clamp(x[i] - step * gradient[i], 0.0, 1.0);
344 const double fn = objective_function(next);
345 if (std::isfinite(fn) && fn < f - 1e-12) {
346 x = std::move(next);
347 f = fn;
348 improved = true;
349 break;
350 }
351 step *= 0.5;
352 }
353 if (!improved) break;
354 }
355 return x;
356 }
357
358 OptimizationResult solve_gradient(std::size_t dimension) {
359 const std::size_t starts = std::max<std::size_t>(1, options_.gradient_restarts);
360 de::NumpyRandomState random(resolved_seed_);
361 std::vector<double> best;
362 double best_fun = std::numeric_limits<double>::infinity();
363 bool timed_out = false;
364 for (std::size_t restart = 0; restart < starts; ++restart) {
365 if (elapsed() >= options_.time_limit) {
366 timed_out = true;
367 break;
368 }
369 std::vector<double> initial =
370 restart == 0 ? std::vector<double>(dimension, 0.5)
371 : random.uniform(0.0, 1.0, dimension);
372 try {
373 std::vector<double> candidate = projected_gradient_descent(initial);
374 const double value = objective_function(candidate);
375 if (std::isfinite(value) && value < best_fun) {
376 best = std::move(candidate);
377 best_fun = value;
378 }
379 } catch (const TimeLimit&) {
380 timed_out = true;
381 break;
382 }
383 }
384 if (best.empty() || (!best_x_.empty() && best_value_ < best_fun)) {
385 if (!best_x_.empty()) {
386 best = best_x_;
387 best_fun = best_value_;
388 } else {
389 return empty_result();
390 }
391 }
392 OptimizationResult result = build_result(best, best_fun);
393 if (timed_out) result.terminated_by = "time_limit";
394 return result;
395 }
396
397 void freeze_layers() {
398 if (!problem_.is_layered() || options_.frozen_layers.empty()) return;
399 const lqn::LqnModel<double>& model = problem_.layered_model();
400 std::set<std::string> frozen(options_.frozen_layers.begin(), options_.frozen_layers.end());
401 std::set<std::string> already;
402 for (const auto& fixed : fixed_variables_) already.insert(fixed.first->name());
403 std::vector<VariablePtr> kept;
404 for (const VariablePtr& variable : free_variables_) {
405 const std::vector<std::string> variable_layers = variable->layers(model);
406 bool intersects = false;
407 for (const std::string& layer : variable_layers)
408 if (frozen.find(layer) != frozen.end()) intersects = true;
409 if (!intersects) {
410 kept.push_back(variable);
411 continue;
412 }
413 if (already.find(variable->name()) != already.end()) continue;
414 const std::optional<Value> value = variable->current_value(model);
415 if (value) {
416 fixed_variables_.push_back({variable, *value});
417 already.insert(variable->name());
418 }
419 }
420 free_variables_ = std::move(kept);
421 }
422
423 std::vector<double> objective_gradient(const std::vector<double>& x) {
424 if (!evaluators_[0].is_layered()) return finite_difference_gradient(x);
425 const std::string& mode = options_.lqn_gradient;
426 if (mode == "partial_sens" || mode == "partial_plus_fd") {
427 ++gradient_calls_;
428 const std::size_t refresh = std::max<std::size_t>(1, options_.fd_refresh);
429 if (!(mode == "partial_plus_fd" && gradient_calls_ % refresh == 0)) {
430 const std::optional<std::vector<double>> gradient =
431 lqn_analytic_gradient(x);
432 if (gradient) return *gradient;
433 }
434 }
435 return finite_difference_gradient(x);
436 }
437
438 double scalar_objective(const EvaluationResult& result,
439 const VariableValues& values) const {
440 double out = problem_.objective()->evaluate_with_penalty(
441 result, values, options_.penalty_weight);
442 for (const ConstraintPtr& constraint : problem_.constraints())
443 out += constraint->evaluate(result, values) * options_.penalty_weight;
444 return out;
445 }
446
447 double scalar_metric_derivative(const EvaluationResult& base,
448 const VariableValues& values,
449 const std::string& kind,
450 const std::string& key) const {
451 const double h = options_.fd_step;
452 EvaluationResult plus = base, minus = base;
453 std::map<std::string, double>* plus_map = nullptr;
454 std::map<std::string, double>* minus_map = nullptr;
455 if (kind == "RespT") {
456 plus_map = &plus.response_times;
457 minus_map = &minus.response_times;
458 } else if (kind == "QLen") {
459 plus_map = &plus.queue_lengths;
460 minus_map = &minus.queue_lengths;
461 } else if (kind == "Tput") {
462 plus_map = &plus.throughputs;
463 minus_map = &minus.throughputs;
464 } else if (kind == "Util") {
465 plus_map = &plus.utilizations;
466 minus_map = &minus.utilizations;
467 } else {
468 return 0.0;
469 }
470 const auto found = plus_map->find(key);
471 if (found == plus_map->end()) return 0.0;
472 (*plus_map)[key] = found->second + h;
473 (*minus_map)[key] = found->second - h;
474 return (scalar_objective(plus, values) - scalar_objective(minus, values)) /
475 (2.0 * h);
476 }
477
478 std::optional<std::vector<double>> lqn_analytic_gradient(
479 const std::vector<double>& x) {
480 if (evaluators_.size() != 1 || free_variables_.empty()) return std::nullopt;
481 for (const VariablePtr& variable : free_variables_)
482 if (!variable->supports_sensitivity()) return std::nullopt;
483 const VariableValues values = evaluators_[0].decode(x);
484 const VariableValues all_values = merge_values(values);
485 const EvaluationResult& result = evaluate(0, values);
486 if (!result.feasible) return std::nullopt;
487
488 auto cached = lqn_sensitivity_cache_.find(values);
489 if (cached == lqn_sensitivity_cache_.end())
490 cached = lqn_sensitivity_cache_.emplace(
491 values, evaluators_[0].evaluate_layered_sensitivities(values)).first;
492 if (!cached->second) return std::nullopt;
493 const LineEvaluator::LayerSensitivityMap& sensitivities = *cached->second;
494 const lqn::LqnModel<double>& model = problem_.layered_model();
495 std::vector<double> gradient(x.size(), 0.0);
496 std::size_t offset = 0;
497 for (const VariablePtr& variable : free_variables_) {
498 const auto row = sensitivities.find(variable->sensitivity_key(model));
499 if (row != sensitivities.end()) {
500 const std::map<std::string, std::string> targets =
501 variable->sensitivity_metric_targets(model);
502 double scalar_rate = 0.0;
503 const struct {
504 const char* kind;
505 double LayerSensitivity::*field;
506 } metrics[] = {{"Tput", &LayerSensitivity::throughput},
510 for (const auto& metric : metrics) {
511 const auto target = targets.find(metric.kind);
512 if (target == targets.end()) continue;
513 scalar_rate += scalar_metric_derivative(
514 result, all_values, metric.kind, target->second) *
515 row->second.*(metric.field);
516 }
517 const auto value = all_values.find(variable->name());
518 if (value != all_values.end())
519 gradient[offset] = scalar_rate * variable->rate_jacobian(value->second) *
520 variable->decode_jacobian(x[offset]);
521 }
522 offset += variable->dimension();
523 }
524 return gradient;
525 }
526
527 std::vector<ConstraintPtr> all_constraints() const {
528 std::vector<ConstraintPtr> constraints = problem_.objective()->constraints;
529 constraints.insert(constraints.end(), problem_.constraints().begin(),
530 problem_.constraints().end());
531 return constraints;
532 }
533
534 OptimizationResult build_result(const std::vector<double>& x, double objective_value) {
535 OptimizationResult out;
536 out.objective_value = objective_value;
537 out.variable_values = evaluators_[0].decode(x);
538 out.iterations = iterations_;
539 out.solve_time = elapsed();
540 out.convergence_history = convergence_history_;
541 for (const LineEvaluator& evaluator : evaluators_)
542 out.model_evaluations += evaluator.evaluation_count();
543
544 const VariableValues all_values = merge_values(out.variable_values);
545 out.feasible = true;
546 for (std::size_t i = 0; i < evaluators_.size(); ++i) {
547 const EvaluationResult& result = evaluate(i, out.variable_values);
548 if (!result.feasible) {
549 out.feasible = false;
550 continue;
551 }
552 for (const ConstraintPtr& constraint : all_constraints()) {
553 const double violation = constraint->evaluate(result, all_values);
554 if (violation > 0.0) {
555 out.feasible = false;
556 out.constraint_violations[constraint->name()] = std::max(
557 out.constraint_violations[constraint->name()], violation);
558 }
559 }
560 }
561 if (elapsed() >= options_.time_limit)
562 out.terminated_by = "time_limit";
563 else if (iterations_ >= options_.max_iterations)
564 out.terminated_by = "iterations";
565 else
566 out.terminated_by = "convergence";
567 return out;
568 }
569
570 OptimizationResult empty_result() const {
571 OptimizationResult out;
572 out.objective_value = 0.0;
573 out.feasible = true;
574 out.terminated_by = "empty";
575 return out;
576 }
577
578 const OptimizationProblem& problem_;
579 LineOptSolverOptions options_;
581 std::vector<VariablePtr> free_variables_;
582 std::vector<std::pair<VariablePtr, Value>> fixed_variables_;
583 VariableValues fixed_values_;
584 std::vector<LineEvaluator> evaluators_;
585 std::vector<double> scenario_weights_;
586 std::vector<std::map<VariableValues, EvaluationResult>> caches_;
587 Clock::time_point start_;
588 std::size_t iterations_ = 0;
589 double best_value_ = std::numeric_limits<double>::infinity();
590 std::vector<double> best_x_;
591 std::vector<double> convergence_history_;
592 std::size_t gradient_calls_ = 0;
593 std::uint64_t resolved_seed_ = 0;
594 std::map<VariableValues, std::optional<LineEvaluator::LayerSensitivityMap>>
595 lqn_sensitivity_cache_;
596};
597
598} // namespace opt
599} // namespace line
600
601#endif
Malformed or inconsistent input (dimensions, negative populations, ...).
Definition error.h:37
InputError(const std::string &what)
Definition error.h:39
std::function< mva::AvgResult< double >( const qn::NetworkStruct< double > &)> SolveFunction
Definition evaluator.h:49
std::map< std::string, LayerSensitivity > LayerSensitivityMap
Definition evaluator.h:51
LineOptSolver(const OptimizationProblem &problem, LineOptSolverOptions options={}, LineEvaluator::SolveFunction solve={})
OptimizationResult solve()
std::vector< std::string > validate() const
Definition problem.h:79
const std::vector< VariablePtr > & variables() const
Definition problem.h:109
const std::vector< Scenario > & scenarios() const
Definition problem.h:113
const OptimizationModel & model_variant() const
Definition problem.h:108
const std::vector< std::pair< VariablePtr, Value > > & fixed_variables() const
Definition problem.h:112
Port of matlab/src/opt/+opt/+de/DifferentialEvolution.m.
The exception types the port throws.
std::shared_ptr< Constraint > ConstraintPtr
Definition objectives.h:34
std::shared_ptr< DecisionVariable > VariablePtr
Definition variables.h:160
std::map< std::string, Value > VariableValues
Definition results.h:33
std::vector< T > solve(const Matrix< T > &A, const std::vector< T > &b)
Convenience: solve Ax = b, leaving A and b untouched.
Definition lu.h:158
The optimization problem: a model, the decision variables to search over, an objective,...
LineOptSolverOptions & set_strategy(std::string value)
LineOptSolverOptions & set_penalty_weight(double value)
LineOptSolverOptions & set_gradient_restarts(std::size_t value)
std::vector< std::string > frozen_layers
LineOptSolverOptions & clear_seed()
LineOptSolverOptions & set_recombination(double value)
LineOptSolverOptions & set_mutation(double low, double high)
LineOptSolverOptions & set_scenario_aggregation(std::string value)
LineOptSolverOptions & set_fd_step(double value)
LineOptSolverOptions & set_fd_refresh(std::size_t value)
LineOptSolverOptions & set_fd_step_layered(double value)
LineOptSolverOptions & set_frozen_layers(std::vector< std::string > value)
LineOptSolverOptions & set_lqn_gradient(std::string value)
LineOptSolverOptions & set_optimizer(std::string value)
LineOptSolverOptions & set_time_limit(double value)
LineOptSolverOptions & set_popsize(std::size_t value)
LineOptSolverOptions & set_seed(std::uint64_t value)
LineOptSolverOptions & set_tolerance(double value)
std::optional< std::uint64_t > seed
LineOptSolverOptions & set_max_iterations(std::size_t value)
LineOptSolverOptions & set_verbose(bool value)