Files
ortools-clone/ortools/sat/feasibility_jump.h
Corentin Le Molgat b4b226801b update include guards
2025-11-05 11:54:02 +01:00

699 lines
25 KiB
C++

// Copyright 2010-2025 Google LLC
// 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.
#ifndef ORTOOLS_SAT_FEASIBILITY_JUMP_H_
#define ORTOOLS_SAT_FEASIBILITY_JUMP_H_
#include <algorithm>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <limits>
#include <memory>
#include <string>
#include <utility>
#include <vector>
#include "absl/container/flat_hash_map.h"
#include "absl/functional/any_invocable.h"
#include "absl/log/check.h"
#include "absl/random/distributions.h"
#include "absl/strings/str_join.h"
#include "absl/strings/string_view.h"
#include "absl/synchronization/mutex.h"
#include "absl/types/span.h"
#include "ortools/sat/constraint_violation.h"
#include "ortools/sat/integer_base.h"
#include "ortools/sat/linear_model.h"
#include "ortools/sat/restart.h"
#include "ortools/sat/sat_parameters.pb.h"
#include "ortools/sat/stat_tables.h"
#include "ortools/sat/subsolver.h"
#include "ortools/sat/synchronization.h"
#include "ortools/sat/util.h"
#include "ortools/util/sorted_interval_list.h"
#include "ortools/util/time_limit.h"
namespace operations_research::sat {
class CompoundMoveBuilder;
// This class lazily caches the results of `compute_jump(var)` which returns a
// <delta, score> pair.
// Variables' scores can be manually modified using MutableScores (if the
// optimal jump is known not to change), or marked for recomputation on the next
// call to GetJump(var) by calling Recompute.
class JumpTable {
public:
JumpTable() = default;
void SetComputeFunction(
absl::AnyInvocable<std::pair<int64_t, double>(int) const> compute_jump);
void RecomputeAll(int num_variables);
// Gets the current jump delta and score, recomputing if necessary.
std::pair<int64_t, double> GetJump(int var);
// If the new optimum value and score is known, users can update it directly.
// e.g. after weight rescaling, or after changing a binary variable.
void SetJump(int var, int64_t delta, double score);
// Recompute the jump for `var` when `GetJump(var)` is next called.
void Recompute(int var);
bool NeedRecomputation(int var) const { return needs_recomputation_[var]; }
double Score(int var) const { return scores_[var]; }
// Advanced usage, allows users to read possibly stale deltas for incremental
// score updates.
absl::Span<const int64_t> Deltas() const {
return absl::MakeConstSpan(deltas_);
}
absl::Span<const double> Scores() const {
return absl::MakeConstSpan(scores_);
}
absl::Span<double> MutableScores() { return absl::MakeSpan(scores_); }
// For debugging and testing.
//
// Note if you have very high weights (e.g. when using decay), the tolerances
// in this function are likely too tight.
bool JumpIsUpToDate(int var) const;
private:
absl::AnyInvocable<std::pair<int64_t, double>(int) const> compute_jump_;
// For each variable, we store:
// - A jump delta which represents a change in variable value:
// new_value = old + delta, which is non-zero except for fixed variables.
// - The associated weighted feasibility violation change if we take this
// jump.
std::vector<int64_t> deltas_;
std::vector<double> scores_;
std::vector<bool> needs_recomputation_;
};
// Accessing Domain can be expensive, so we maintain vector of bool for the
// hot spots.
class VarDomainWrapper {
public:
explicit VarDomainWrapper(SharedBoundsManager* shared_bounds)
: shared_bounds_id_(
shared_bounds == nullptr ? 0 : shared_bounds->RegisterNewId()),
shared_bounds_(shared_bounds) {}
Domain operator[](int var) const { return domains_[var]; }
bool HasTwoValues(int var) const { return has_two_values_[var]; }
size_t size() const { return domains_.size(); }
void resize(int num_vars) {
domains_.resize(num_vars);
has_two_values_.resize(num_vars);
is_fixed_.resize(num_vars, false);
objective_is_positive_.resize(num_vars, false);
objective_is_negative_.resize(num_vars, false);
has_better_objective_value_.resize(num_vars, false);
}
void Set(int var, Domain d) {
has_two_values_[var] = d.HasTwoValues();
if (is_fixed_[var]) {
// The code here assume that once fixed, a variable stays that way.
CHECK(d.IsFixed());
} else if (d.IsFixed()) {
is_fixed_[var] = true;
fixed_vars_.push_back(var);
}
domains_[var] = std::move(d);
}
// Return false if one of the domain becomes empty (UNSAT). This might happen
// while we are cleaning up all workers at the end of a search.
bool UpdateFromSharedBounds() {
if (shared_bounds_ == nullptr) return true;
shared_bounds_->GetChangedBounds(shared_bounds_id_, &tmp_variables_,
&tmp_new_lower_bounds_,
&tmp_new_upper_bounds_);
for (int i = 0; i < tmp_variables_.size(); ++i) {
const int var = tmp_variables_[i];
const Domain new_domain = domains_[var].IntersectionWith(
Domain(tmp_new_lower_bounds_[i], tmp_new_upper_bounds_[i]));
if (new_domain.IsEmpty()) return false;
Set(var, new_domain);
}
return true;
}
absl::Span<const Domain> AsSpan() const { return domains_; }
void InitializeObjective(const CpModelProto& cp_model_proto) {
if (!cp_model_proto.has_objective()) return;
const int num_terms = cp_model_proto.objective().vars().size();
for (int i = 0; i < num_terms; ++i) {
const int var = cp_model_proto.objective().vars(i);
const int coeff = cp_model_proto.objective().coeffs(i);
objective_is_positive_[var] = coeff > 0;
objective_is_negative_[var] = coeff < 0;
}
}
bool IsFixed(int var) const { return is_fixed_[var]; }
bool HasBetterObjectiveValue(int var) const {
return has_better_objective_value_[var];
}
// Tricky: this must be called on solution value change or domains update.
void OnValueChange(int var, int64_t value) {
has_better_objective_value_[var] =
(objective_is_positive_[var] && value > domains_[var].Min()) ||
(objective_is_negative_[var] && value < domains_[var].Max());
}
absl::Span<const int> FixedVariables() const { return fixed_vars_; }
private:
const int shared_bounds_id_;
SharedBoundsManager* shared_bounds_;
// Basically fixed once and for all.
std::vector<bool> objective_is_positive_;
std::vector<bool> objective_is_negative_;
// Depends on domain updates.
std::vector<Domain> domains_;
std::vector<bool> has_two_values_;
std::vector<bool> is_fixed_;
std::vector<int> fixed_vars_;
// This is the only one that depends on the current solution value.
std::vector<bool> has_better_objective_value_;
// Temporary data for UpdateFromSharedBounds()
std::vector<int> tmp_variables_;
std::vector<int64_t> tmp_new_lower_bounds_;
std::vector<int64_t> tmp_new_upper_bounds_;
};
// Local search counters. This can either be the stats of one run without
// restart or some aggregation of such runs.
struct LsCounters {
int64_t num_batches = 0;
int64_t num_perturbations = 0;
int64_t num_linear_evals = 0;
int64_t num_linear_moves = 0;
int64_t num_general_evals = 0;
int64_t num_general_moves = 0;
int64_t num_backtracks = 0;
int64_t num_compound_moves = 0;
int64_t num_weight_updates = 0;
int64_t num_scores_computed = 0;
void AddFrom(const LsCounters& o) {
num_batches += o.num_batches;
num_perturbations += o.num_perturbations;
num_linear_evals += o.num_linear_evals;
num_linear_moves += o.num_linear_moves;
num_general_evals += o.num_general_evals;
num_general_moves += o.num_general_moves;
num_backtracks += o.num_backtracks;
num_compound_moves += o.num_compound_moves;
num_weight_updates += o.num_weight_updates;
num_scores_computed += o.num_scores_computed;
}
};
// The parameters used by the local search code.
struct LsOptions {
// This one never changes.
// - If true, each restart is independent from the other. This is nice because
// it plays well with the theoretical Luby restart sequence.
// - If false, we always "restart" from the current state, but we perturb it
// or just reset the constraint weight. We currently use this one way less
// often.
bool use_restart = true;
// These are randomized each restart by Randomize().
double perturbation_probability = 0.0;
bool use_decay = true;
bool use_compound_moves = true;
bool use_objective = true; // No effect if there are no objective.
// Allows to identify which options worked well.
std::string name() const {
std::vector<absl::string_view> parts;
parts.reserve(5);
if (use_restart) parts.push_back("restart");
if (use_decay) parts.push_back("decay");
if (use_compound_moves) parts.push_back("compound");
if (perturbation_probability > 0) parts.push_back("perturb");
if (use_objective) parts.push_back("obj");
return absl::StrJoin(parts, "_");
}
// In order to collect statistics by options.
template <typename H>
friend H AbslHashValue(H h, const LsOptions& o) {
return H::combine(std::move(h), o.use_restart, o.perturbation_probability,
o.use_decay, o.use_compound_moves, o.use_objective);
}
bool operator==(const LsOptions& o) const {
return use_restart == o.use_restart &&
perturbation_probability == o.perturbation_probability &&
use_decay == o.use_decay &&
use_compound_moves == o.use_compound_moves &&
use_objective == o.use_objective;
}
void Randomize(const SatParameters& params, ModelRandomGenerator* random) {
perturbation_probability =
absl::Bernoulli(*random, 0.5)
? 0.0
: params.feasibility_jump_var_randomization_probability();
use_decay = absl::Bernoulli(*random, 0.5);
use_compound_moves = absl::Bernoulli(*random, 0.5);
use_objective = absl::Bernoulli(*random, 0.5);
}
};
// Each FeasibilityJumpSolver work on many LsState in an interleaved parallel
// fashion. Each "batch of moves" will update one of these state. Restart
// heuristic are also on a per state basis.
//
// This allows to not use O(problem size) per state while having a more
// diverse set of heuristics.
struct LsState {
// The score of a solution is just the sum of infeasibility of each
// constraint weighted by these weights.
std::vector<int64_t> solution;
std::vector<double> weights;
std::shared_ptr<const SharedSolutionRepository<int64_t>::Solution>
base_solution;
// Depending on the options, we use an exponentially decaying constraint
// weight like for SAT activities.
double bump_value = 1.0;
// If using compound moves, these will be discounted on a new incumbent then
// re-converge to weights after some exploration. Search will repeatedly pick
// moves with negative WeightedViolationDelta using these weights.
//
// We limit the discrepancy in compound move search (i.e. limit the number of
// backtracks to any ancestor of the current leaf). This is set to 0 whenever
// a new incumbent is found or weights are updated, and increased at fixed
// point. Weights are only increased if no moves are found with discrepancy 2.
// Empirically we have seen very few moves applied with discrepancy > 2.
int compound_move_max_discrepancy = 0;
std::vector<double> compound_weights;
std::vector<bool> in_compound_weight_changed;
std::vector<int> compound_weight_changed;
std::unique_ptr<CompoundMoveBuilder> move;
// Counters for a "non-restarted" run.
LsCounters counters;
// Strategy
LsOptions options;
// Global counters, incremented across restart.
int64_t num_restarts = 0;
int64_t num_solutions_imported = 0;
// When this reach zero, we restart / perturbate or trigger something.
int64_t num_batches_before_change = 0;
// Used by LS to know the rank of the starting solution for this state.
int64_t last_solution_rank = std::numeric_limits<int64_t>::max();
// Tricky: If this changed since last time, we need to recompute the
// compound moves as the objective constraint bound changed.
IntegerValue saved_inner_objective_lb = 0;
IntegerValue saved_inner_objective_ub = 0;
};
// Shared set of local search states that we work on.
class SharedLsStates {
public:
// Important: max_parallelism should be greater or equal than the actual
// number of thread sharing this class, otherwise the code will break.
SharedLsStates(absl::string_view name, const SatParameters& params,
SharedStatTables* stat_tables)
: name_(name), params_(params), stat_tables_(stat_tables) {
// We always start with at least 8 states.
// We will create more if there are more parallel workers as needed.
for (int i = 0; i < 8; ++i) CreateNewState();
}
~SharedLsStates();
void CreateNewState() {
const int index = states_.size();
states_.emplace_back(new LsState());
taken_.push_back(false);
num_selected_.push_back(0);
// We add one no-restart per 16 states and put it last.
states_.back()->options.use_restart = (index % 16 != 15);
}
// Returns the next available state in round-robin fashion.
// This is thread safe. If we respect the max_parallelism guarantee, then
// all states should be independent.
LsState* GetNextState() {
absl::MutexLock mutex_lock(mutex_);
int next = -1;
const int num_states = states_.size();
for (int i = 0; i < num_states; ++i) {
const int index = round_robin_index_;
round_robin_index_ = (round_robin_index_ + 1) % num_states;
if (taken_[index]) continue;
if (next == -1 || num_selected_[index] < num_selected_[next]) {
next = index;
}
}
if (next == -1) {
// We need more parallelism and create a new state.
next = num_states;
CreateNewState();
}
--states_[next]->num_batches_before_change;
taken_[next] = true;
num_selected_[next]++;
return states_[next].get();
}
void Release(LsState* state) {
absl::MutexLock mutex_lock(mutex_);
for (int i = 0; i < states_.size(); ++i) {
if (state == states_[i].get()) {
taken_[i] = false;
break;
}
}
}
void ResetLubyCounter() {
absl::MutexLock mutex_lock(mutex_);
luby_counter_ = 0;
}
// We share a global running Luby sequence for all the "restart" state.
// Note that we randomize the parameters on each restart.
//
// Hack: options.use_restart is constant, so we are free to inspect it.
// Also if options.use_restart, then num_batches_before_change is only
// modified under lock, so this code should be thread safe.
void ConfigureNextLubyRestart(LsState* state) {
absl::MutexLock mutex_lock(mutex_);
const int factor = std::max(1, params_.feasibility_jump_restart_factor());
CHECK(state->options.use_restart);
const int64_t next = factor * SUniv(++luby_counter_);
state->num_batches_before_change = next;
}
// Accumulate in the relevant bucket the counters of the given states.
void CollectStatistics(const LsState& state) {
if (state.counters.num_batches == 0) return;
absl::MutexLock mutex_lock(mutex_);
options_to_stats_[state.options].AddFrom(state.counters);
options_to_num_restarts_[state.options]++;
}
private:
const std::string name_;
const SatParameters& params_;
SharedStatTables* stat_tables_;
mutable absl::Mutex mutex_;
int round_robin_index_ = 0;
std::vector<std::unique_ptr<LsState>> states_;
std::vector<bool> taken_;
std::vector<int> num_selected_;
int luby_counter_ = 0;
absl::flat_hash_map<LsOptions, LsCounters> options_to_stats_;
absl::flat_hash_map<LsOptions, int> options_to_num_restarts_;
};
// Implements and heuristic similar to the one described in the paper:
// "Feasibility Jump: an LP-free Lagrangian MIP heuristic", Bjørnar
// Luteberget, Giorgio Sartor, 2023, Mathematical Programming Computation.
//
// This is basically a Guided local search (GLS) with a nice algo to know what
// value an integer variable should move to (its jump value). For binary, it
// can only be swapped, so the situation is easier.
//
// TODO(user): If we have more than one of these solver, we might want to share
// the evaluator memory between them. Right now we basically keep a copy of the
// model and its transpose for each FeasibilityJumpSolver.
class FeasibilityJumpSolver : public SubSolver {
public:
FeasibilityJumpSolver(const absl::string_view name,
SubSolver::SubsolverType type,
const LinearModel* linear_model, SatParameters params,
std::shared_ptr<SharedLsStates> ls_states,
ModelSharedTimeLimit* shared_time_limit,
SharedResponseManager* shared_response,
SharedBoundsManager* shared_bounds,
SharedLsSolutionRepository* shared_hints,
SharedStatistics* shared_stats,
SharedStatTables* stat_tables)
: SubSolver(name, type),
linear_model_(linear_model),
params_(params),
states_(std::move(ls_states)),
shared_time_limit_(shared_time_limit),
shared_response_(shared_response),
shared_hints_(shared_hints),
stat_tables_(stat_tables),
random_(params_),
var_domains_(shared_bounds) {
shared_time_limit_->UpdateLocalLimit(&time_limit_);
}
// If VLOG_IS_ON(1), it will export a bunch of statistics.
~FeasibilityJumpSolver() override;
// No synchronization needed for TaskIsAvailable().
void Synchronize() final {}
// Shall we delete this subsolver?
bool IsDone() final {
if (!model_is_supported_.load()) return true;
// We are done after the first task is done in the FIRST_SOLUTION mode.
return type() == SubSolver::FIRST_SOLUTION &&
shared_response_->first_solution_solvers_should_stop()->load();
}
bool TaskIsAvailable() final {
if (IsDone()) return false;
if (task_generated_.load()) return false;
if (shared_response_->ProblemIsSolved()) return false;
if (shared_time_limit_->LimitReached()) return false;
return shared_response_->HasFeasibleSolution() ==
(type() == SubSolver::INCOMPLETE);
}
std::function<void()> GenerateTask(int64_t /*task_id*/) final;
private:
void ImportState();
void ReleaseState();
// Return false if we could not initialize the evaluator in the time limit.
bool Initialize();
void ResetCurrentSolution(bool use_hint, bool use_objective,
double perturbation_probability);
void PerturbateCurrentSolution(double perturbation_probability);
std::string OneLineStats() const;
absl::Span<const double> ScanWeights() const {
return absl::MakeConstSpan(state_->options.use_compound_moves
? state_->compound_weights
: state_->weights);
}
// Returns the weighted violation delta plus epsilon * the objective delta.
double ComputeScore(absl::Span<const double> weights, int var, int64_t delta,
bool linear_only);
// Computes the optimal value for variable v, considering only the violation
// of linear constraints.
std::pair<int64_t, double> ComputeLinearJump(int var);
// Computes the optimal value for variable v, considering all constraints
// (assuming violation functions are convex).
std::pair<int64_t, double> ComputeGeneralJump(int var);
// Marks all variables whose jump value may have changed due to the last
// update, except for `changed var`.
void MarkJumpsThatNeedToBeRecomputed(int changed_var);
// Moves.
bool DoSomeLinearIterations();
bool DoSomeGeneralIterations();
// Returns true if an improving move was found.
bool ScanRelevantVariables(int num_to_scan, int* var, int64_t* value,
double* score);
// Increases the weight of the currently infeasible constraints.
// Ensures jumps remains consistent.
void UpdateViolatedConstraintWeights();
// Returns true if it is possible that `var` may have value that reduces
// weighted violation or improve the objective.
// Note that this is independent of the actual weights used.
bool ShouldScan(int var) const;
void AddVarToScan(int var);
void RecomputeVarsToScan();
// Resets the weights used to find compound moves.
// Ensures the following invariant holds afterwards:
// compound_weights[c] = weights_[c] if c is violated, and epsilon *
// weights_[c] otherwise.
void ResetChangedCompoundWeights();
// Returns true if we should push this change to move_.
// `novelty` is the total discount applied to the score due to using
// `cumulative_weights_`, should always be positive (modulo floating-point
// errors).
bool ShouldExtendCompoundMove(double score, double novelty);
// Validates each element in num_violated_constraints_per_var_ against
// evaluator_->ViolatedConstraints.
bool SlowCheckNumViolatedConstraints() const;
double DeterministicTime() const {
return evaluator_->DeterministicTime() + num_ops_ * 1e-8;
}
const LinearModel* linear_model_;
SatParameters params_;
std::shared_ptr<SharedLsStates> states_;
ModelSharedTimeLimit* shared_time_limit_;
TimeLimit time_limit_;
SharedResponseManager* shared_response_;
SharedLsSolutionRepository* shared_hints_;
SharedStatTables* stat_tables_;
ModelRandomGenerator random_;
VarDomainWrapper var_domains_;
// Synchronization Booleans.
//
// Note that we don't fully support all type of model, and we will abort by
// setting the model_is_supported_ bool to false when we detect this.
bool is_initialized_ = false;
std::atomic<bool> model_is_supported_ = true;
std::atomic<bool> task_generated_ = false;
bool time_limit_crossed_ = false;
std::unique_ptr<LsEvaluator> evaluator_;
std::vector<bool> var_occurs_in_non_linear_constraint_;
JumpTable jumps_;
std::vector<double> for_weight_update_;
// The current sate we work on.
LsState* state_;
// A list of variables that might be relevant to check for improving jumps.
std::vector<bool> in_vars_to_scan_;
FixedCapacityVector<int> vars_to_scan_;
std::vector<int64_t> tmp_breakpoints_;
// For counting the dtime. See DeterministicTime().
int64_t num_ops_ = 0;
};
// This class helps keep track of moves that change more than one variable.
// Mainly this class keeps track of how to backtrack back to the local minimum
// as you make a sequence of exploratory moves, so in order to commit a compound
// move, you just need to call `Clear` instead of Backtracking over the changes.
class CompoundMoveBuilder {
public:
explicit CompoundMoveBuilder(int num_variables)
: var_on_stack_(num_variables, false) {}
// Adds an atomic move to the stack.
// `var` must not be on the stack (this is DCHECKed).
void Push(int var, int64_t prev_value, double score);
// Sets var, val and score to a move that will revert the most recent atomic
// move on the stack, and pops this move from the stack.
// Returns false if the stack is empty.
bool Backtrack(int* var, int64_t* value, double* score);
// Removes all moves on the stack.
void Clear();
// Returns the number of variables in the move.
int Size() const { return stack_.size(); }
// Returns true if this var has been set in this move already,
bool OnStack(int var) const;
// Returns the sum of scores of atomic moved pushed to this compound move.
double Score() const {
return stack_.empty() ? 0.0 : stack_.back().cumulative_score;
}
double BestChildScore() const {
return stack_.empty() ? 0.0 : stack_.back().best_child_score;
}
// Returns the number of backtracks to any ancestor of the current leaf.
int Discrepancy() const {
return stack_.empty() ? 0 : stack_.back().discrepancy;
}
// Returns true if all prev_values on the stack are in the appropriate domain.
bool StackValuesInDomains(absl::Span<const Domain> var_domains) const;
private:
struct UnitMove {
int var;
int64_t prev_value;
// Note that this stores the score of reverting to prev_value.
double score;
// Instead of tracking this on the compound move, we store the partial sums
// to avoid numerical issues causing negative scores after backtracking.
double cumulative_score;
// Used to avoid infinite loops, this tracks the best score of any immediate
// children (and not deeper descendants) to avoid re-exploring the same
// prefix.
double best_child_score = 0.0;
int discrepancy = 0;
};
std::vector<bool> var_on_stack_;
std::vector<UnitMove> stack_;
};
} // namespace operations_research::sat
#endif // ORTOOLS_SAT_FEASIBILITY_JUMP_H_