From 547ed8148348b5ab948070f20ec790da994bffb3 Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 19 Aug 2026 11:59:37 -0700 Subject: [PATCH 1/7] Add structural primal heuristics with an arc flow recognizer Introduces a recognizer interface and the two dispatchers that drive it: one running on a task during presolve, one alongside the root relaxation. A pass recognizes a model, constructs a point, and the dispatcher validates it in solver space before publication, so a detection mistake cannot reach the tree. The arc flow pass is the first recognizer. Recognition is staged by cost so an unrecognized model is turned away before its constraint matrix is read. --- cpp/src/mip_heuristics/CMakeLists.txt | 4 +- cpp/src/mip_heuristics/early_heuristic.cuh | 51 +- .../feasibility_jump/early_cpufj.cu | 4 +- .../feasibility_jump/early_gpufj.cu | 4 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 174 ++- .../feasibility_jump/fj_cpu.cuh | 27 + cpp/src/mip_heuristics/mip_constants.hpp | 13 +- cpp/src/mip_heuristics/solve.cu | 42 +- cpp/src/mip_heuristics/solver.cu | 50 + cpp/src/mip_heuristics/solver_context.cuh | 4 + cpp/src/mip_heuristics/structural/arc_flow.cu | 1169 +++++++++++++++++ .../mip_heuristics/structural/arc_flow.cuh | 68 + .../structural/early_structural.cu | 273 ++++ .../structural/early_structural.cuh | 149 +++ cpp/tests/internal/CMakeLists.txt | 1 + cpp/tests/mip/arc_flow_test.cu | 537 ++++++++ 16 files changed, 2476 insertions(+), 94 deletions(-) create mode 100644 cpp/src/mip_heuristics/structural/arc_flow.cu create mode 100644 cpp/src/mip_heuristics/structural/arc_flow.cuh create mode 100644 cpp/src/mip_heuristics/structural/early_structural.cu create mode 100644 cpp/src/mip_heuristics/structural/early_structural.cuh create mode 100644 cpp/tests/mip/arc_flow_test.cu diff --git a/cpp/src/mip_heuristics/CMakeLists.txt b/cpp/src/mip_heuristics/CMakeLists.txt index 7705465512..5907872fef 100644 --- a/cpp/src/mip_heuristics/CMakeLists.txt +++ b/cpp/src/mip_heuristics/CMakeLists.txt @@ -44,7 +44,9 @@ set(MIP_NON_LP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/feasibility_jump_kernels.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/fj_cpu.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_cpufj.cu - ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_gpufj.cu) + ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_gpufj.cu + ${CMAKE_CURRENT_SOURCE_DIR}/structural/early_structural.cu + ${CMAKE_CURRENT_SOURCE_DIR}/structural/arc_flow.cu) # Choose which files to include based on build mode if(BUILD_LP_ONLY) diff --git a/cpp/src/mip_heuristics/early_heuristic.cuh b/cpp/src/mip_heuristics/early_heuristic.cuh index 6654470732..150d27b767 100644 --- a/cpp/src/mip_heuristics/early_heuristic.cuh +++ b/cpp/src/mip_heuristics/early_heuristic.cuh @@ -29,30 +29,15 @@ using early_incumbent_callback_t = std::function class early_heuristic_t { public: - early_heuristic_t(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t& tolerances, - early_incumbent_callback_t incumbent_callback) + explicit early_heuristic_t(early_incumbent_callback_t incumbent_callback) : incumbent_callback_(std::move(incumbent_callback)) { RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); - - // Build and preprocess on the original handle, then copy onto our own handle - // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). - problem_t temp_problem(op_problem, tolerances, false); - temp_problem.preprocess_problem(); - temp_problem.handle_ptr->sync_stream(); - problem_ptr_ = std::make_unique>(temp_problem, &handle_); - - solution_ptr_ = std::make_unique>(*problem_ptr_); - thrust::fill(handle_.get_thrust_policy(), - solution_ptr_->assignment.begin(), - solution_ptr_->assignment.end(), - f_t{0}); - solution_ptr_->clamp_within_bounds(); } bool solution_found() const { return solution_found_; } @@ -60,6 +45,7 @@ class early_heuristic_t { // Return the best objective converted to user-space (sense-aware, offset-aware). f_t get_best_user_objective() const { + cuopt_assert(problem_ptr_ != nullptr, "initialize_problem was not called"); return problem_ptr_->get_user_obj_from_solver_obj(best_objective_); } // Set the incumbent threshold. `obj` must be in THIS heuristic's solver-space @@ -72,10 +58,35 @@ class early_heuristic_t { protected: ~early_heuristic_t() = default; + // Must run on the thread owning op_problem's handle, and before start(). + void initialize_problem(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) + { + cuopt_assert(problem_ptr_ == nullptr, "initialize_problem called twice"); + + // Build and preprocess on the original handle, then copy onto our own handle + // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). + problem_t temp_problem(op_problem, tolerances, false); + temp_problem.preprocess_problem(); + temp_problem.handle_ptr->sync_stream(); + problem_ptr_ = std::make_unique>(temp_problem, &handle_); + + solution_ptr_ = std::make_unique>(*problem_ptr_); + thrust::fill(handle_.get_thrust_policy(), + solution_ptr_->assignment.begin(), + solution_ptr_->assignment.end(), + f_t{0}); + solution_ptr_->clamp_within_bounds(); + } + // NOT thread-safe. solver_obj is in solver-space (always minimization). // Uses a private CUDA stream to avoid racing with the FJ solver's stream. - void try_update_best(f_t solver_obj, const std::vector& assignment) + // `name` attributes the incumbent to a sub-heuristic; null attributes it to the pass itself. + void try_update_best(f_t solver_obj, + const std::vector& assignment, + const char* name = nullptr) { + cuopt_assert(problem_ptr_ != nullptr, "initialize_problem was not called"); if (solver_obj >= best_objective_) { return; } best_objective_ = solver_obj; @@ -92,7 +103,7 @@ class early_heuristic_t { // Log and callback are deferred to the shared incumbent_callback_ which enforces // global monotonicity across all early heuristic instances. if (incumbent_callback_) { - incumbent_callback_(solver_obj, user_obj, user_assignment, Derived::name()); + incumbent_callback_(solver_obj, user_obj, user_assignment, name ? name : Derived::name()); } } diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index ba14e657d5..1a882ac0f5 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -16,9 +16,9 @@ early_cpufj_t::early_cpufj_t( const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t& tolerances, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>( - op_problem, tolerances, std::move(incumbent_callback)) + : early_heuristic_t>(std::move(incumbent_callback)) { + this->initialize_problem(op_problem, tolerances); } template diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu index 697f26e6df..c1e46ff707 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu @@ -22,9 +22,9 @@ template early_gpufj_t::early_gpufj_t(const optimization_problem_t& op_problem, const mip_solver_settings_t& settings, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>( - op_problem, settings.get_tolerances(), std::move(incumbent_callback)) + : early_heuristic_t>(std::move(incumbent_callback)) { + this->initialize_problem(op_problem, settings.get_tolerances()); context_ptr_ = std::make_unique>( &this->handle_, this->problem_ptr_.get(), settings); } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 57a6a89479..d30839a961 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1419,97 +1419,76 @@ void finalize_fj_cpu_host_initialization( } template -static std::unique_ptr> init_fj_cpu_from_host_lp( - const lp_problem_t& problem, - const std::vector& variable_types, +std::unique_ptr> init_fj_cpu_from_host_model( + fj_cpu_host_model_t model, const std::vector& seed_assignment, - const simplex_solver_settings_t& settings, + const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption_flag, - int64_t seed) + fj_settings_t settings) { using f_t2 = typename type_2::type; - cuopt_assert(variable_types.size() >= static_cast(problem.num_cols), + const i_t n_variables = model.n_variables; + const i_t n_constraints = model.n_constraints; + const i_t nnz = static_cast(model.variables.size()); + cuopt_assert(static_cast(model.offsets.size()) == n_constraints + 1, "offset size mismatch"); + cuopt_assert(model.coefficients.size() == model.variables.size(), "csr size mismatch"); + cuopt_assert(static_cast(model.var_types.size()) == n_variables, "variable type size mismatch"); + cuopt_assert(static_cast(model.objective.size()) == n_variables, "objective size mismatch"); + cuopt_assert(static_cast(model.var_lb.size()) == n_variables && + static_cast(model.var_ub.size()) == n_variables, + "variable bound size mismatch"); + cuopt_assert(static_cast(model.row_lb.size()) == n_constraints && + static_cast(model.row_ub.size()) == n_constraints, + "row bound size mismatch"); - typename mip_solver_settings_t::tolerances_t tolerances{}; - tolerances.absolute_tolerance = settings.primal_tol; - tolerances.relative_tolerance = settings.zero_tol; - tolerances.integrality_tolerance = settings.integer_tol; - tolerances.absolute_mip_gap = settings.absolute_mip_gap_tol; - tolerances.relative_mip_gap = settings.relative_mip_gap_tol; - - const i_t n_variables = problem.num_cols; - const i_t n_constraints = problem.num_rows; - - csr_matrix_t csr_A(problem.num_rows, problem.num_cols, problem.A.nnz()); - problem.A.to_compressed_row(csr_A); - std::vector coefficients = csr_A.x; - std::vector variables = csr_A.j; - std::vector offsets = csr_A.row_start; - std::vector constraint_lower_bounds = problem.rhs; - std::vector constraint_upper_bounds = problem.rhs; std::vector variable_bounds(n_variables); - std::vector cpufj_variable_types(n_variables); std::vector is_binary_variable(n_variables, 0); i_t n_integer_vars = 0; - for (i_t j = 0; j < n_variables; ++j) { - variable_bounds[j] = f_t2{problem.lower[j], problem.upper[j]}; - const auto var_type = variable_types[j]; - cpufj_variable_types[j] = - var_type == variable_type_t::CONTINUOUS ? var_t::CONTINUOUS : var_t::INTEGER; - - const bool is_integer = cpufj_variable_types[j] == var_t::INTEGER; - const bool is_binary = is_integer && - integer_equal(problem.lower[j], f_t{0}, settings.integer_tol) && - integer_equal(problem.upper[j], f_t{1}, settings.integer_tol); - if (is_integer) { ++n_integer_vars; } + variable_bounds[j] = f_t2{model.var_lb[j], model.var_ub[j]}; + if (model.var_types[j] != var_t::INTEGER) { continue; } + ++n_integer_vars; + const bool is_binary = + integer_equal(model.var_lb[j], f_t{0}, tolerances.integrality_tolerance) && + integer_equal(model.var_ub[j], f_t{1}, tolerances.integrality_tolerance); if (is_binary) { is_binary_variable[j] = 1; } } - const i_t nnz = static_cast(variables.size()); - csc_matrix_t reverse_csc(n_constraints, n_variables, nnz); - csr_A.to_compressed_col(reverse_csc); - std::vector reverse_coefficients = std::move(reverse_csc.x); - std::vector reverse_constraints = std::move(reverse_csc.i); - std::vector reverse_offsets = std::move(reverse_csc.col_start); - std::vector projected_seed(n_variables, f_t{0}); for (i_t j = 0; j < n_variables; ++j) { f_t value = j < static_cast(seed_assignment.size()) ? seed_assignment[j] : f_t{0}; - value = std::clamp(value, problem.lower[j], problem.upper[j]); - if (variable_types[j] != variable_type_t::CONTINUOUS) { - value = std::clamp(std::round(value), problem.lower[j], problem.upper[j]); + value = std::clamp(value, model.var_lb[j], model.var_ub[j]); + if (model.var_types[j] != var_t::CONTINUOUS) { + value = std::clamp(std::round(value), model.var_lb[j], model.var_ub[j]); } projected_seed[j] = value; } - fj_settings_t fj_settings; - fj_settings.mode = fj_mode_t::EXIT_NON_IMPROVING; - fj_settings.n_of_minimums_for_exit = std::numeric_limits::max(); - fj_settings.time_limit = std::numeric_limits::infinity(); - fj_settings.iteration_limit = std::numeric_limits::max(); - fj_settings.update_weights = true; - fj_settings.feasibility_run = false; - fj_settings.seed = seed >= 0 ? seed : cuopt::seed_generator::get_seed(); + csr_matrix_t csr_A(n_constraints, n_variables, nnz); + csr_A.x = std::move(model.coefficients); + csr_A.j = std::move(model.variables); + csr_A.row_start = std::move(model.offsets); + csc_matrix_t reverse_csc(n_constraints, n_variables, nnz); + csr_A.to_compressed_col(reverse_csc); auto fj_cpu = std::make_unique>(preemption_flag); fj_cpu->view = typename fj_t::climber_data_t::view_t{}; fj_cpu->pb_ptr = nullptr; - fj_cpu->settings = fj_settings; - - fj_cpu->h_reverse_coefficients = std::move(reverse_coefficients); - fj_cpu->h_reverse_constraints = std::move(reverse_constraints); - fj_cpu->h_reverse_offsets = std::move(reverse_offsets); - fj_cpu->h_coefficients = std::move(coefficients); - fj_cpu->h_offsets = std::move(offsets); - fj_cpu->h_variables = std::move(variables); - fj_cpu->h_obj_coeffs = problem.objective; + fj_cpu->settings = settings; + + fj_cpu->h_reverse_coefficients = std::move(reverse_csc.x); + fj_cpu->h_reverse_constraints = std::move(reverse_csc.i); + fj_cpu->h_reverse_offsets = std::move(reverse_csc.col_start); + fj_cpu->h_coefficients = std::move(csr_A.x); + fj_cpu->h_offsets = std::move(csr_A.row_start); + fj_cpu->h_variables = std::move(csr_A.j); + fj_cpu->h_obj_coeffs = std::move(model.objective); fj_cpu->h_var_bounds = std::move(variable_bounds); - fj_cpu->h_cstr_lb = std::move(constraint_lower_bounds); - fj_cpu->h_cstr_ub = std::move(constraint_upper_bounds); - fj_cpu->h_var_types = std::move(cpufj_variable_types); + fj_cpu->h_cstr_lb = std::move(model.row_lb); + fj_cpu->h_cstr_ub = std::move(model.row_ub); + fj_cpu->h_var_types = std::move(model.var_types); fj_cpu->h_is_binary_variable = std::move(is_binary_variable); fj_cpu->h_cstr_left_weights.resize(n_constraints, 1.0); @@ -1531,6 +1510,59 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( return fj_cpu; } +template +static std::unique_ptr> init_fj_cpu_from_host_lp( + const lp_problem_t& problem, + const std::vector& variable_types, + const std::vector& seed_assignment, + const simplex_solver_settings_t& settings, + std::atomic& preemption_flag, + int64_t seed) +{ + cuopt_assert(variable_types.size() >= static_cast(problem.num_cols), + "variable type size mismatch"); + + typename mip_solver_settings_t::tolerances_t tolerances{}; + tolerances.absolute_tolerance = settings.primal_tol; + tolerances.relative_tolerance = settings.zero_tol; + tolerances.integrality_tolerance = settings.integer_tol; + tolerances.absolute_mip_gap = settings.absolute_mip_gap_tol; + tolerances.relative_mip_gap = settings.relative_mip_gap_tol; + + fj_cpu_host_model_t model; + model.n_variables = problem.num_cols; + model.n_constraints = problem.num_rows; + + csr_matrix_t csr_A(problem.num_rows, problem.num_cols, problem.A.nnz()); + problem.A.to_compressed_row(csr_A); + model.coefficients = std::move(csr_A.x); + model.variables = std::move(csr_A.j); + model.offsets = std::move(csr_A.row_start); + // Standard form: every row is an equality. + model.row_lb = problem.rhs; + model.row_ub = problem.rhs; + model.var_lb = problem.lower; + model.var_ub = problem.upper; + model.objective = problem.objective; + model.var_types.resize(model.n_variables); + for (i_t j = 0; j < model.n_variables; ++j) { + model.var_types[j] = + variable_types[j] == variable_type_t::CONTINUOUS ? var_t::CONTINUOUS : var_t::INTEGER; + } + + fj_settings_t fj_settings; + fj_settings.mode = fj_mode_t::EXIT_NON_IMPROVING; + fj_settings.n_of_minimums_for_exit = std::numeric_limits::max(); + fj_settings.time_limit = std::numeric_limits::infinity(); + fj_settings.iteration_limit = std::numeric_limits::max(); + fj_settings.update_weights = true; + fj_settings.feasibility_run = false; + fj_settings.seed = seed >= 0 ? seed : cuopt::seed_generator::get_seed(); + + return init_fj_cpu_from_host_model( + std::move(model), seed_assignment, tolerances, preemption_flag, fj_settings); +} + template static void sanity_checks(fj_cpu_climber_t& fj_cpu) { @@ -1853,6 +1885,12 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_host_model( + fj_cpu_host_model_t model, + const std::vector& seed_assignment, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, @@ -1873,6 +1911,12 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_host_model( + fj_cpu_host_model_t model, + const std::vector& seed_assignment, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 718c89615d..3fc94f2f59 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -204,4 +204,31 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +// A model held entirely on the host, row major. Row bounds are carried as given, so ranged and one +// sided rows need no slack columns. +template +struct fj_cpu_host_model_t { + i_t n_variables{0}; + i_t n_constraints{0}; + std::vector coefficients; + std::vector variables; + std::vector offsets; + std::vector row_lb; + std::vector row_ub; + std::vector var_lb; + std::vector var_ub; + std::vector objective; + std::vector var_types; +}; + +// CPUFJ init from host arrays alone: no problem_t, no device allocation, no handle. The model is +// taken by value and moved from. Callers holding a device problem want init_fj_cpu_standalone. +template +std::unique_ptr> init_fj_cpu_from_host_model( + fj_cpu_host_model_t model, + const std::vector& seed_assignment, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings); + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index f3fb68343a..67240e6095 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -15,9 +15,16 @@ #define PDLP_INSTANTIATE_FLOAT 1 /* @brief Minimimum number of threads to enable each part of the MIP Solver */ -#define CUOPT_MIP_FJ_REQUIRED_THREAD_COUNT 8 -#define CUOPT_MIP_EARLY_GPUFJ_REQUIRED_THREAD_COUNT 3 -#define CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT 2 +#define CUOPT_MIP_FJ_REQUIRED_THREAD_COUNT 8 +#define CUOPT_MIP_EARLY_GPUFJ_REQUIRED_THREAD_COUNT 3 +#define CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT 2 +#define CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT 2 + +// Thread slots the root structural pass must leave free for the tasks that share the team while it +// runs: the B&B task itself, dual simplex, the diversity manager, the concurrent barrier, clique +// extension, the LS scratch climber and the ls_cpu_fj taskloop. B&B's own worker pools are not +// created until the cut loop ends, which is what makes the remaining slots safe to take. +#define CUOPT_MIP_ROOT_STRUCTURAL_RESERVED_THREADS 8 #define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 #define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index f55aca6878..12a82c020b 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -19,6 +19,7 @@ #include #include #include +#include #include #include @@ -65,6 +66,7 @@ #include #include +#include #include namespace cuopt::mathematical_optimization { @@ -249,7 +251,13 @@ mip_solution_t run_mip_solver( // optimization_problem_t). Its solver-space differs from both the first-pass FJ (original // problem) and B&B (post-trivial- presolve), so initial_upper_bound (user-space) is converted // via problem.get_solver_obj_from_user_obj. + + // Must outlive early_cpufj/early_structural below, whose destructors join the tasks. + std::mutex papilo_callback_mutex; + f_t papilo_best_solver_obj = std::numeric_limits::infinity(); + std::unique_ptr> early_cpufj; + std::unique_ptr> early_structural; bool run_early_cpufj = problem.has_papilo_presolve_data() && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && problem.original_problem_ptr->get_n_integers() > 0; @@ -268,13 +276,22 @@ mip_solution_t run_mip_solver( semi_continuous_original_num_variables = mip_solver_settings_accessor::get_semi_continuous_original_num_variables( settings), - ctx_ptr = &solver.context, + ctx_ptr = &solver.context, + papilo_num_original_vars = problem.get_papilo_original_num_variables(), + &papilo_callback_mutex, + &papilo_best_solver_obj, early_fj_start](f_t solver_obj, f_t user_obj, const std::vector& assignment, const char* heuristic_name) { + // Both producers are built on *problem.original_problem_ptr; solver_obj shares one space. + std::lock_guard lock(papilo_callback_mutex); + if (solver_obj >= papilo_best_solver_obj) { return; } + papilo_best_solver_obj = solver_obj; + std::vector user_assignment; presolver_ptr->uncrush_primal_solution(assignment, user_assignment); + cuopt_assert(user_assignment.size() == (size_t)papilo_num_original_vars, "Size mismatch"); ctx_ptr->initial_incumbent_assignment = user_assignment; ctx_ptr->initial_upper_bound = user_obj; double elapsed = @@ -302,6 +319,15 @@ mip_solution_t run_mip_solver( early_cpufj->start(); solver.context.early_cpufj_ptr = early_cpufj.get(); CUOPT_LOG_DEBUG("Started early CPUFJ on papilo-presolved problem during cuOpt presolve"); + + early_structural = std::make_unique>( + *problem.original_problem_ptr, settings.get_tolerances(), incumbent_callback); + if (std::isfinite(initial_upper_bound)) { + early_structural->set_best_objective( + problem.get_solver_obj_from_user_obj(initial_upper_bound)); + } + early_structural->start(); + solver.context.early_structural_ptr = early_structural.get(); } auto presolved_sol = solver.run_solver(); @@ -498,6 +524,7 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::unique_ptr> early_cpufj; std::unique_ptr> early_gpufj; + std::unique_ptr> early_structural; bool run_early_fj = run_presolve && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && op_problem.get_n_integers() > 0 && op_problem.get_n_constraints() > 0; @@ -556,6 +583,9 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::make_unique>(op_problem, settings, early_fj_callback); early_gpufj->start(); CUOPT_LOG_DEBUG("Started early GPUFJ during presolve"); + early_structural = std::make_unique>( + op_problem, settings.get_tolerances(), early_fj_callback); + early_structural->start(); } auto constexpr const dual_postsolve = false; @@ -650,6 +680,16 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p early_cpufj.reset(); } + if (early_structural) { + early_structural->stop(); + if (early_structural->solution_found()) { + CUOPT_LOG_DEBUG("Early %s (original) found incumbent with objective %.6e during presolve", + early_structural->recognized_name(), + early_structural->get_best_objective()); + } + early_structural.reset(); + } + // Add early-heuristic incumbents (original-space) to initial_solutions. // PaPILO crushing + validation happens downstream in add_user_given_solutions(). if (!early_incumbent_pool.empty()) { diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index f8eac0c4d8..bd0dbb1908 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -25,11 +25,15 @@ #include #include +#include #include #include #include +#include +#include +#include #include #include #include @@ -227,6 +231,15 @@ solution_t mip_solver_t::run_solver() } } + if (context.early_structural_ptr) { + context.early_structural_ptr->stop(); + if (context.early_structural_ptr->solution_found()) { + CUOPT_LOG_DEBUG("Early %s found incumbent with user-space objective %g during presolve", + context.early_structural_ptr->recognized_name(), + context.early_structural_ptr->get_best_user_objective()); + } + } + if (!presolve_success) { CUOPT_LOG_INFO("Problem proven infeasible in presolve"); sol.set_problem_fully_reduced(); @@ -480,6 +493,36 @@ solution_t mip_solver_t::run_solver() } } + // Runs alongside the root relaxation and the cut loop, on the thread slots B&B's worker pools + // only claim once the cut loop ends. Recognition is the gate: on a model with no structure to + // exploit this costs one host scan and nothing is launched. + const i_t root_structural_lanes = + context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC + ? 0 + : std::max(num_threads - CUOPT_MIP_ROOT_STRUCTURAL_RESERVED_THREADS, 0); + // The generators detect_symmetry already produced above, as plain column permutations: a + // structural heuristic recovers its own object from the model and only needs to know which + // columns are interchangeable, not how that was established. + std::vector> column_symmetry; + if (context.symmetry != nullptr) { + const auto& generators = context.symmetry->generators; + column_symmetry.reserve(generators.num_generators()); + for (size_t index = 0; index < generators.num_generators(); ++index) { + column_symmetry.push_back( + generators.get_generator(static_cast(index)).dense_permutation()); + } + } + std::unique_ptr> root_structural; + if (root_structural_lanes > 0 && !context.settings.heuristics_only) { + root_structural = std::make_unique>( + *context.problem_ptr, + context.settings.get_tolerances(), + context.preempt_heuristic_solver_, + column_symmetry.empty() ? nullptr : &column_symmetry, + root_structural_lanes); + if (!root_structural->recognized()) { root_structural.reset(); } + } + #pragma omp taskgroup { if (!context.settings.heuristics_only) { @@ -489,6 +532,13 @@ solution_t mip_solver_t::run_solver() } } + if (root_structural) { +#pragma omp task default(shared) priority(CUOPT_DEFAULT_TASK_PRIORITY) + { + root_structural->run(); + } + } + // Start the primal heuristics context.diversity_manager_ptr = &dm; sol = dm.run_solver(); diff --git a/cpp/src/mip_heuristics/solver_context.cuh b/cpp/src/mip_heuristics/solver_context.cuh index f98386cbaf..f656c50b45 100644 --- a/cpp/src/mip_heuristics/solver_context.cuh +++ b/cpp/src/mip_heuristics/solver_context.cuh @@ -33,6 +33,9 @@ class diversity_manager_t; template class early_cpufj_t; +template +class early_structural_t; + // Aggregate structure containing the global context of the solving process for convenience: // The current problem, user settings, raft handle and statistics objects template @@ -66,6 +69,7 @@ struct mip_solver_context_t { work_unit_scheduler_t work_unit_scheduler_{5.0}; early_cpufj_t* early_cpufj_ptr{nullptr}; + early_structural_t* early_structural_ptr{nullptr}; // Best upper bound from early heuristics, in user-space. // Must be converted to the target solver-space before use: // - B&B: problem_ptr->get_solver_obj_from_user_obj(initial_upper_bound) diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu new file mode 100644 index 0000000000..0d6c12969f --- /dev/null +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -0,0 +1,1169 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "arc_flow.cuh" + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +namespace { + +// Number of source-to-sink paths the frontier dynamic program enumerates. The state is a +// canonical tuple of this many frontier nodes, so the reachable state count grows roughly as the +// node count raised to one less than this. +constexpr int arcflow_paths_supported = 2; +// Upper bound on the expanded covering demand, which is the number of dynamic program levels. +constexpr int arcflow_max_tokens = 20000; +constexpr int arcflow_max_col_entries = 3; +// Ceiling on the reconstruction history. Reaching it forces a beam and gives up exactness. +constexpr size_t arcflow_history_bytes_max = size_t{32} << 20; + +// Structural inference reads objective coefficients and matrix entries at f_t precision, so the +// residual a genuine arc-flow model leaves behind is bounded by that precision and not by double. +struct arcflow_tol_t { + double abs{1e-9}; + double rel{1e-9}; +}; + +template +arcflow_tol_t structural_tolerance() +{ + const double eps = 8.0 * (double)(std::numeric_limits::epsilon()); + return arcflow_tol_t{std::max(1e-9, eps), std::max(1e-9, eps)}; +} + +enum class row_role_t : uint8_t { flow, cover }; + +struct arc_t { + int from{-1}; + int to{-1}; + int label{-1}; + int col{-1}; + double cost{0.0}; +}; + +// Everything the construction needs, derived without reading any row, column or name order. +struct arc_flow_model_t { + int n_nodes{0}; + int n_labels{0}; + arcflow_tol_t tol; + + std::vector phi; // potential per node + std::vector path_start; // one entry per path, expanded by supply multiplicity + std::vector demand; // per label + std::vector displacement; // per label + std::vector slope; // per label, NaN when the cost slope is unidentifiable + std::vector arc_offset; // per label, CSR into arcs + std::vector arcs; // grouped by label, ascending by tail within a label + + // A path may end at a node either through an explicit unlabelled arc or through slack in the + // node's conservation row. A negative terminator_col marks the row-slack encoding, which sets + // no variable. + std::vector terminator_col; + std::vector terminator_cost; + std::vector terminator_capacity; +}; + +// Live state of the search. Kept for the current level only. +struct frontier_t { + std::array node{}; + double cost{0.0}; +}; + +// Retained for every level so the winning path can be walked back. Deliberately narrow: this is +// what the memory budget is spent on. +struct parent_t { + int prev{-1}; + int arc{-1}; +}; + +struct candidate_t { + frontier_t front; + parent_t parent; +}; + +struct arc_flow_result_t { + std::vector columns; // selected columns, repeated for multiplicity + double cost{0.0}; + bool exact{true}; // false once the history budget forced a beam + size_t peak_raw{0}; // widest level before merging equal states + size_t peak_kept{0}; // widest level actually retained +}; + +bool close_to(double a, double b, double scale, const arcflow_tol_t& tol) +{ + return std::abs(a - b) <= tol.abs + tol.rel * scale; +} + +bool is_integral(double v, const arcflow_tol_t& tol) +{ + return std::abs(v - std::round(v)) <= tol.abs; +} + +bool is_known(double v) { return !std::isnan(v); } + +// --------------------------------------------------------------------------------------------- +// Host mirror of the problem. The reverse (column major) matrix is the natural view here: the +// detector always asks which rows a column touches, never the other way round. +// --------------------------------------------------------------------------------------------- +template +struct host_problem_t { + i_t n_variables{0}; + i_t n_constraints{0}; + arcflow_tol_t tol; + std::vector csc_values; + std::vector csc_rows; + std::vector csc_offsets; + std::vector row_lb; + std::vector row_ub; + std::vector obj; + std::vector var_lb; + std::vector var_ub; + std::vector var_types; +}; + +// Recognition is three gates in ascending cost, so a model that cannot match is turned away before +// its matrix is read. Together they are the whole of recognition: the detector proper runs later, +// in solve(). Both problem sources drive the same gates, which is what keeps them agreeing. +bool arcflow_accepts_shape(int64_t n_variables, int64_t n_constraints, int64_t nnz) +{ + if (n_variables <= 0 || n_constraints <= 0) { return false; } + return nnz > 0 && nnz <= (int64_t)arcflow_max_col_entries * n_variables; +} + +// Every row is either two sided or bounded from below only, and both kinds must occur: the first +// become conservation rows and the second covering rows. +template +bool arcflow_accepts_bounds(const std::vector& row_lb, const std::vector& row_ub) +{ + int64_t n_flow_candidates = 0; + int64_t n_cover_candidates = 0; + for (size_t r = 0; r < row_lb.size(); ++r) { + const bool lo_fin = std::isfinite((double)row_lb[r]); + const bool hi_fin = std::isfinite((double)row_ub[r]); + if (lo_fin && hi_fin) { + n_flow_candidates++; + } else if (lo_fin) { + n_cover_candidates++; + } else { + return false; + } + } + return n_flow_candidates > 0 && n_cover_candidates > 0; +} + +// The only matrix facts recognition needs. Derived from whichever layout the caller already holds, +// which is what keeps the transpose off the declining path. +struct arcflow_profile_t { + arcflow_profile_t(int64_t n_variables, int64_t n_constraints) + : col_entries(n_variables, 0), + row_min_mag(n_constraints, std::numeric_limits::infinity()), + row_max_mag(n_constraints, 0.0) + { + } + + std::vector col_entries; + std::vector row_min_mag; + std::vector row_max_mag; +}; + +template +arcflow_profile_t profile_from_csr(i_t n_variables, + i_t n_constraints, + const std::vector& csr_values, + const std::vector& csr_cols, + const std::vector& csr_offsets) +{ + arcflow_profile_t p(n_variables, n_constraints); + for (i_t r = 0; r < n_constraints; ++r) { + for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { + const i_t col = csr_cols[k]; + cuopt_assert(col >= 0 && col < n_variables, "Column index out of range"); + const double mag = std::abs((double)csr_values[k]); + ++p.col_entries[col]; + p.row_min_mag[r] = std::min(p.row_min_mag[r], mag); + p.row_max_mag[r] = std::max(p.row_max_mag[r], mag); + } + } + return p; +} + +template +arcflow_profile_t profile_from_csc(i_t n_variables, + i_t n_constraints, + const std::vector& csc_values, + const std::vector& csc_rows, + const std::vector& csc_offsets) +{ + arcflow_profile_t p(n_variables, n_constraints); + for (i_t j = 0; j < n_variables; ++j) { + p.col_entries[j] = csc_offsets[j + 1] - csc_offsets[j]; + for (i_t k = csc_offsets[j]; k < csc_offsets[j + 1]; ++k) { + const i_t row = csc_rows[k]; + cuopt_assert(row >= 0 && row < n_constraints, "Row index out of range"); + const double mag = std::abs((double)csc_values[k]); + p.row_min_mag[row] = std::min(p.row_min_mag[row], mag); + p.row_max_mag[row] = std::max(p.row_max_mag[row], mag); + } + } + return p; +} + +// The column entry cap, one shared coefficient magnitude per row, and an integral covering demand +// within the level budget. +template +bool arcflow_accepts_profile(const arcflow_profile_t& p, + const std::vector& row_lb, + const std::vector& row_ub, + const arcflow_tol_t& tol) +{ + for (const int64_t entries : p.col_entries) { + if (entries > arcflow_max_col_entries) { return false; } + } + + double cover_demand = 0.0; + for (size_t r = 0; r < p.row_max_mag.size(); ++r) { + if (p.row_max_mag[r] == 0.0 || p.row_min_mag[r] <= tol.abs) { return false; } + if (!close_to(p.row_min_mag[r], p.row_max_mag[r], p.row_max_mag[r], tol)) { return false; } + if (!std::isfinite((double)row_ub[r])) { + const double demand = row_lb[r] / p.row_max_mag[r]; + if (!is_integral(demand, tol) || demand < 1.0 - tol.abs) { return false; } + cover_demand += std::round(demand); + } + } + return cover_demand > 0.0 && cover_demand <= arcflow_max_tokens; +} + +// The model arrives row major and the detector reads it column major, so the transpose is built +// here by counting sort. Rows stay ascending within a column, which build_structure relies on. +template +void transpose_into_csc(const std::vector& csr_values, + const std::vector& csr_cols, + const std::vector& csr_offsets, + host_problem_t& h) +{ + const size_t nnz = csr_values.size(); + h.csc_offsets.assign((size_t)h.n_variables + 1, 0); + for (size_t k = 0; k < nnz; ++k) { + ++h.csc_offsets[(size_t)csr_cols[k] + 1]; + } + for (i_t j = 0; j < h.n_variables; ++j) { + h.csc_offsets[j + 1] += h.csc_offsets[j]; + } + h.csc_rows.assign(nnz, 0); + h.csc_values.assign(nnz, f_t{0}); + std::vector cursor(h.csc_offsets.begin(), h.csc_offsets.end() - 1); + for (i_t r = 0; r < h.n_constraints; ++r) { + for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { + const i_t slot = cursor[csr_cols[k]]++; + h.csc_rows[slot] = r; + h.csc_values[slot] = csr_values[k]; + } + } +} + +// --------------------------------------------------------------------------------------------- +// Row classification +// --------------------------------------------------------------------------------------------- + +struct row_info_t { + row_role_t role{row_role_t::cover}; + double scale{1.0}; // common magnitude of the row coefficients + double lo{0.0}; // bounds divided by scale, later reoriented + double hi{0.0}; +}; + +// A row is usable only if all its coefficients share one magnitude, +// The role is seeded from the bounds and verified afterwards against the column patterns: a flow +// row is two sided, a covering row is bounded from below only. Presolve can turn a flow equality +// into a range row, so the seed must not test for equality. A covering row that acquired a finite +// upper bound is consequently misread as a flow row and rejected downstream; that is a known limit. +template +bool classify_rows(const host_problem_t& h, std::vector& rows) +{ + rows.assign(h.n_constraints, row_info_t{}); + std::vector min_mag(h.n_constraints, std::numeric_limits::infinity()); + std::vector max_mag(h.n_constraints, 0.0); + + for (size_t k = 0; k < h.csc_rows.size(); ++k) { + const int r = h.csc_rows[k]; + const double mag = std::abs((double)h.csc_values[k]); + if (mag <= h.tol.abs) { return false; } + min_mag[r] = std::min(min_mag[r], mag); + max_mag[r] = std::max(max_mag[r], mag); + } + + for (int r = 0; r < h.n_constraints; ++r) { + if (max_mag[r] == 0.0) { return false; } + if (!close_to(min_mag[r], max_mag[r], max_mag[r], h.tol)) { return false; } + + row_info_t info; + info.scale = max_mag[r]; + const double lo = h.row_lb[r] / info.scale; + const double hi = h.row_ub[r] / info.scale; + const bool lo_fin = std::isfinite(lo); + const bool hi_fin = std::isfinite(hi); + + if (lo_fin && hi_fin) { + info.role = row_role_t::flow; + info.lo = lo; + info.hi = hi; + } else if (lo_fin) { + if (!is_integral(lo, h.tol) || lo < 1.0 - h.tol.abs) { return false; } + info.role = row_role_t::cover; + info.lo = std::round(lo); + info.hi = hi; + } else { + return false; + } + rows[r] = info; + } + return true; +} + +// The nonzero flow right-hand sides must all carry one sign. That sign says which incidence of an +// arc is its tail, and their absolute sum is the number of paths. Returns 0 when ambiguous. +double supply_orientation(const std::vector& rows, + const arcflow_tol_t& tol, + double& total) +{ + double positive = 0.0; + double negative = 0.0; + for (const auto& info : rows) { + if (info.role != row_role_t::flow) { continue; } + if (info.lo > tol.abs) { positive += info.lo; } + if (info.hi < -tol.abs) { negative += -info.hi; } + } + if (positive > tol.abs && negative > tol.abs) { return 0.0; } + if (positive > tol.abs) { + total = positive; + return 1.0; + } + if (negative > tol.abs) { + total = negative; + return -1.0; + } + return 0.0; +} + +// --------------------------------------------------------------------------------------------- +// Structure extraction +// --------------------------------------------------------------------------------------------- + +// Walks the columns once and fills the arc set, the covering demands, the path starts and the +// per-node termination capacity. Rejects anything that does not match the labelled arc pattern. +// A column carrying a single flow incidence is accepted only when that incidence is the arc's +// tail: sources are read from the right-hand side, so the mirror encoding of an explicit +// injection arc is out of scope here. +template +bool build_structure(const host_problem_t& h, + std::vector& rows, + arc_flow_model_t& model) +{ + model.tol = h.tol; + + double supply_total = 0.0; + const double sign = supply_orientation(rows, h.tol, supply_total); + if (sign == 0.0 || !is_integral(supply_total, h.tol)) { return false; } + if ((int)std::round(supply_total) != arcflow_paths_supported) { return false; } + + // Reorient so a normalized coefficient of +1 always means "the arc leaves this node". + if (sign < 0.0) { + for (auto& info : rows) { + if (info.role != row_role_t::flow) { continue; } + const double lo = info.lo; + info.lo = -info.hi; + info.hi = -lo; + } + } + + std::vector node_of_row(h.n_constraints, -1); + std::vector label_of_row(h.n_constraints, -1); + for (int r = 0; r < h.n_constraints; ++r) { + if (rows[r].role == row_role_t::flow) { + node_of_row[r] = model.n_nodes++; + } else { + label_of_row[r] = model.n_labels++; + } + } + if (model.n_nodes == 0 || model.n_labels == 0) { return false; } + + model.demand.assign(model.n_labels, 0); + model.terminator_col.assign(model.n_nodes, -1); + model.terminator_cost.assign(model.n_nodes, 0.0); + model.terminator_capacity.assign(model.n_nodes, 0); + + int64_t total_demand = 0; + for (int r = 0; r < h.n_constraints; ++r) { + const int l = label_of_row[r]; + if (l < 0) { continue; } + model.demand[l] = std::round(rows[r].lo); + total_demand += model.demand[l]; + } + if (total_demand <= 0 || total_demand > arcflow_max_tokens) { return false; } + + // Node roles from the oriented conservation bounds. A source emits a fixed number of paths; a + // node whose lower bound allows negative net outflow absorbs them, which is the presolved + // encoding of a loss arc. + for (int r = 0; r < h.n_constraints; ++r) { + const int v = node_of_row[r]; + if (v < 0) { continue; } + const auto& info = rows[r]; + if (info.lo > h.tol.abs) { + if (!close_to(info.lo, info.hi, std::abs(info.lo), h.tol) || !is_integral(info.lo, h.tol)) { + return false; + } + model.path_start.insert(model.path_start.end(), (size_t)std::round(info.lo), v); + } else if (std::abs(info.hi) <= h.tol.abs) { + if (info.lo < -h.tol.abs) { + if (!is_integral(info.lo, h.tol)) { return false; } + model.terminator_capacity[v] = std::round(-info.lo); + } + } else { + return false; + } + } + if ((int)model.path_start.size() != arcflow_paths_supported) { return false; } + + for (int j = 0; j < h.n_variables; ++j) { + if (h.var_types[j] != var_t::INTEGER) { return false; } + if (!std::isfinite((double)h.obj[j])) { return false; } + if (std::abs((double)h.var_lb[j]) > h.tol.abs) { return false; } + const double ub = h.var_ub[j]; + if (!std::isfinite(ub) || ub < 1.0 - h.tol.abs) { return false; } + + const int begin = h.csc_offsets[j]; + const int end = h.csc_offsets[j + 1]; + if (end == begin || end - begin > arcflow_max_col_entries) { return false; } + + int tail = -1; + int head = -1; + int label = -1; + for (int k = begin; k < end; ++k) { + const int r = h.csc_rows[k]; + const double unit = h.csc_values[k] / rows[r].scale; + if (rows[r].role == row_role_t::flow) { + const double oriented = unit * sign; + if (close_to(oriented, 1.0, 1.0, h.tol)) { + if (tail >= 0) { return false; } + tail = node_of_row[r]; + } else if (close_to(oriented, -1.0, 1.0, h.tol)) { + if (head >= 0) { return false; } + head = node_of_row[r]; + } else { + return false; + } + } else { + // A covering incidence is positive irrespective of the flow orientation. + if (!close_to(unit, 1.0, 1.0, h.tol)) { return false; } + if (label >= 0) { return false; } + label = label_of_row[r]; + } + } + + if (tail >= 0 && head >= 0) { + if (label < 0) { return false; } + // With this bound the arc capacity can never bind: a use of the arc consumes one of the + // label's tokens, and there are exactly demand[label] of them. + if ((double)model.demand[label] > ub + h.tol.abs) { return false; } + model.arcs.push_back(arc_t{tail, head, label, j, (double)h.obj[j]}); + } else if (tail >= 0 && label < 0) { + // Explicit loss arc: it leaves the node and never arrives, so it ends a path. A node that + // already absorbs paths through row slack would need the two capacities apportioned, which + // no model in this family does, so reject rather than guess. + if (model.terminator_col[tail] >= 0 || model.terminator_capacity[tail] > 0) { return false; } + model.terminator_col[tail] = j; + model.terminator_cost[tail] = h.obj[j]; + model.terminator_capacity[tail] = std::floor(ub + h.tol.abs); + } else { + return false; + } + } + + if (model.arcs.empty()) { return false; } + + std::stable_sort(model.arcs.begin(), model.arcs.end(), [](const arc_t& a, const arc_t& b) { + if (a.label != b.label) { return a.label < b.label; } + if (a.from != b.from) { return a.from < b.from; } + return a.cost < b.cost; + }); + model.arc_offset.assign(model.n_labels + 1, 0); + for (const auto& arc : model.arcs) { + model.arc_offset[arc.label + 1]++; + } + for (int l = 0; l < model.n_labels; ++l) { + model.arc_offset[l + 1] += model.arc_offset[l]; + if (model.arc_offset[l] == model.arc_offset[l + 1]) { return false; } + } + cuopt_assert(model.arc_offset.back() == (int)model.arcs.size(), + "arc CSR offsets must cover every arc"); + return true; +} + +// --------------------------------------------------------------------------------------------- +// Potential and cost model +// --------------------------------------------------------------------------------------------- + +// The potential is recovered from the objective rather than from any index: within a label the +// cost is affine in the potential of the arc's tail, so one label with enough distinct costs fixes +// the potential on every node it touches, and the remaining labels are fitted and extended from +// there. The result is an affine image of the true potential, which leaves the order that +// consumes it unchanged, since a common positive factor cancels out of every comparison. +bool derive_potential(arc_flow_model_t& model, const std::atomic& preemption_flag) +{ + const int n_labels = model.n_labels; + const arcflow_tol_t tol = model.tol; + + int reference = -1; + size_t best_count = 0; + std::vector costs; + for (int l = 0; l < n_labels; ++l) { + costs.clear(); + for (int k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { + costs.push_back(model.arcs[k].cost); + } + std::sort(costs.begin(), costs.end()); + const size_t distinct = (size_t)(std::unique(costs.begin(), costs.end()) - costs.begin()); + if (distinct > best_count) { + best_count = distinct; + reference = l; + } + } + if (reference < 0 || best_count < 2) { return false; } + + const double unknown = std::numeric_limits::quiet_NaN(); + model.phi.assign(model.n_nodes, unknown); + model.slope.assign(n_labels, unknown); + model.displacement.assign(n_labels, unknown); + std::vector intercept(n_labels, unknown); + + for (int k = model.arc_offset[reference]; k < model.arc_offset[reference + 1]; ++k) { + model.phi[model.arcs[k].from] = model.arcs[k].cost; + } + + // Every round that changes anything resolves at least one potential, slope or displacement, so + // this many rounds is an upper bound on the productive ones and the loop always reaches a true + // fixpoint. The bound exists to keep a malformed model from spinning, not to cap propagation + // depth: a chain of labels longer than any fixed constant still resolves. + const long max_rounds = 2L * n_labels + model.n_nodes + 2L; + long rounds = 0; + for (; rounds < max_rounds; ++rounds) { + if (preemption_flag.load()) { return false; } + bool progress = false; + for (int l = 0; l < n_labels; ++l) { + const int begin = model.arc_offset[l]; + const int end = model.arc_offset[l + 1]; + + if (!is_known(model.slope[l])) { + int lowest = -1; + int highest = -1; + for (int k = begin; k < end; ++k) { + const double p = model.phi[model.arcs[k].from]; + if (!is_known(p)) { continue; } + if (lowest < 0 || p < model.phi[model.arcs[lowest].from]) { lowest = k; } + if (highest < 0 || p > model.phi[model.arcs[highest].from]) { highest = k; } + } + if (lowest >= 0 && highest >= 0) { + const double lo_phi = model.phi[model.arcs[lowest].from]; + const double hi_phi = model.phi[model.arcs[highest].from]; + if (!close_to(lo_phi, hi_phi, std::abs(hi_phi), tol)) { + model.slope[l] = + (model.arcs[highest].cost - model.arcs[lowest].cost) / (hi_phi - lo_phi); + intercept[l] = model.arcs[lowest].cost - model.slope[l] * lo_phi; + progress = true; + } + } + } + if (is_known(model.slope[l]) && std::abs(model.slope[l]) > tol.abs) { + for (int k = begin; k < end; ++k) { + const int from = model.arcs[k].from; + if (is_known(model.phi[from])) { continue; } + model.phi[from] = (model.arcs[k].cost - intercept[l]) / model.slope[l]; + progress = true; + } + } + + // Close the potential through the arcs themselves once the displacement is pinned down. + if (!is_known(model.displacement[l])) { + for (int k = begin; k < end; ++k) { + const double from = model.phi[model.arcs[k].from]; + const double to = model.phi[model.arcs[k].to]; + if (is_known(from) && is_known(to)) { + model.displacement[l] = to - from; + progress = true; + break; + } + } + } + if (is_known(model.displacement[l])) { + for (int k = begin; k < end; ++k) { + const int from = model.arcs[k].from; + const int to = model.arcs[k].to; + if (is_known(model.phi[from]) && !is_known(model.phi[to])) { + model.phi[to] = model.phi[from] + model.displacement[l]; + progress = true; + } else if (is_known(model.phi[to]) && !is_known(model.phi[from])) { + model.phi[from] = model.phi[to] - model.displacement[l]; + progress = true; + } + } + } + } + if (!progress) { break; } + } + cuopt_assert(rounds < max_rounds, "propagation must reach a fixpoint within its progress bound"); + + for (double p : model.phi) { + if (!is_known(p)) { return false; } + } + for (double p : model.displacement) { + if (!is_known(p)) { return false; } + } + + double phi_scale = 0.0; + for (double p : model.phi) { + phi_scale = std::max(phi_scale, std::abs(p)); + } + if (phi_scale <= tol.abs) { return false; } + + // Orient the potential so displacements are positive. A consistent potential whose + // displacements are all strictly positive is exactly what makes the arc graph acyclic: a cycle + // would need its displacements to sum to zero. + double displacement_sum = 0.0; + for (double p : model.displacement) { + displacement_sum += p; + } + if (displacement_sum < 0.0) { + for (auto& p : model.phi) { + p = -p; + } + for (auto& p : model.displacement) { + p = -p; + } + for (auto& w : model.slope) { + w = -w; + } + } + for (double p : model.displacement) { + if (p <= tol.abs) { return false; } + } + + // The construction consumes exactly the demanded number of tokens per label, so it never + // oversatisfies a covering row. That is only cost preserving when covering more cannot pay, + // and the weighted completion time argument behind the token order needs the same condition. + for (double w : model.slope) { + if (is_known(w) && w < -tol.abs) { return false; } + } + + // Verify against every arc, not just the two points each fit was built from. The cost residual + // matters as much as the potential: the order is read off the fitted slopes, so a label whose + // costs are not affine in the potential would be ordered on a meaningless quantity. + double cost_scale = 0.0; + for (const auto& arc : model.arcs) { + cost_scale = std::max(cost_scale, std::abs(arc.cost)); + } + for (const auto& arc : model.arcs) { + if (!close_to( + model.phi[arc.to], model.phi[arc.from] + model.displacement[arc.label], phi_scale, tol)) { + return false; + } + if (!is_known(model.slope[arc.label])) { continue; } + const double predicted = model.slope[arc.label] * model.phi[arc.from] + intercept[arc.label]; + if (!close_to(predicted, arc.cost, cost_scale, tol)) { return false; } + } + return true; +} + +// --------------------------------------------------------------------------------------------- +// Token order +// --------------------------------------------------------------------------------------------- + +// Weighted shortest processing time: labels by decreasing cost slope over displacement, compared +// by cross multiplication so the ordering never turns on the rounding of a division. Both +// quantities come from the arc set, so the order is invariant under any permutation of the model, +// and the affine freedom left in the potential scales every ratio by the same positive factor. +// +// A slope needs two arcs at distinct potentials to fit, so a label that has only one, or whose arcs +// all share a tail, has no identified ratio. Its position is still determined, by reachability +// rather than by cost, and `ordering_exact` reports whether any label was placed that way: the +// ordered family searched is then not the Smith-ordered one and the result is heuristic. +std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact) +{ + std::vector lowest_phi(model.n_labels, 0.0); + for (int l = 0; l < model.n_labels; ++l) { + double lowest = std::numeric_limits::infinity(); + for (int k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { + lowest = std::min(lowest, model.phi[model.arcs[k].from]); + } + lowest_phi[l] = lowest; + } + + std::vector ordered; + std::vector unidentified; + ordered.reserve(model.n_labels); + for (int l = 0; l < model.n_labels; ++l) { + if (is_known(model.slope[l])) { + ordered.push_back(l); + } else { + unidentified.push_back(l); + } + } + + const arcflow_tol_t tol = model.tol; + const auto cross = [&](int a, int b) { + return (long double)model.slope[a] * (long double)model.displacement[b]; + }; + + // Comparing ratios within a tolerance is not an ordering: approximate equality is not transitive, + // so three ratios pairwise within one step but further apart end to end make the comparator + // cyclic, and std::sort on a cyclic comparator is undefined behaviour rather than a bad order. + // The tolerance is applied once, to cut the ratios into classes, and everything the sort sees + // afterwards is compared exactly. + std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { + const long double lhs = cross(a, b); + const long double rhs = cross(b, a); + if (lhs != rhs) { return lhs > rhs; } + return a < b; + }); + std::vector ratio_class(model.n_labels, 0); + for (size_t position = 1; position < ordered.size(); ++position) { + const int previous = ordered[position - 1]; + const int current = ordered[position]; + const long double lhs = cross(previous, current); + const long double rhs = cross(current, previous); + const long double scale = std::max(std::abs(lhs), std::abs(rhs)); + const bool tied = std::abs(lhs - rhs) <= tol.abs + tol.rel * (double)scale; + ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); + } + std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { + if (ratio_class[a] != ratio_class[b]) { return ratio_class[a] < ratio_class[b]; } + if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } + if (model.displacement[a] != model.displacement[b]) { + return model.displacement[a] < model.displacement[b]; + } + return a < b; + }); + + int64_t total_demand = 0; + for (int64_t d : model.demand) { + total_demand += d; + } + std::vector tokens; + tokens.reserve((size_t)total_demand); + for (int l : ordered) { + tokens.insert(tokens.end(), (size_t)model.demand[l], l); + } + + // A label whose slope could not be fitted has too few arcs to place by weighted shortest + // processing time, but its arcs still say where it can go: a frontier can only reach potential + // x once the tokens consumed so far displace at least x. Insert it at the first such position + // rather than assuming an unfittable label belongs at the front. + ordering_exact = unidentified.empty(); + std::sort(unidentified.begin(), unidentified.end(), [&](int a, int b) { + if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } + return a < b; + }); + for (int l : unidentified) { + double consumed = 0.0; + size_t slot = 0; + while (slot < tokens.size() && consumed < lowest_phi[l]) { + consumed += model.displacement[tokens[slot]]; + ++slot; + } + tokens.insert(tokens.begin() + (ptrdiff_t)slot, (size_t)model.demand[l], l); + } + return tokens; +} + +// --------------------------------------------------------------------------------------------- +// Frontier dynamic program +// --------------------------------------------------------------------------------------------- + +bool state_less(const frontier_t& a, const frontier_t& b) +{ + return std::lexicographical_compare(a.node.begin(), a.node.end(), b.node.begin(), b.node.end()); +} + +bool state_equal(const frontier_t& a, const frontier_t& b) { return a.node == b.node; } + +std::optional run_dp(const arc_flow_model_t& model, + const std::vector& tokens, + const std::atomic& preemption_flag) +{ + const int n_tokens = tokens.size(); + if (n_tokens == 0) { return std::nullopt; } + + arc_flow_result_t result; + + // Only the reconstruction history is retained across levels, so the budget is charged against + // what is actually kept as it accumulates. Deriving a fixed width from the budget up front + // would have to assume the peak width at every level, which beams searches that fit easily. + size_t retained_bytes = 0; + + std::vector> history; + history.reserve((size_t)n_tokens); + + frontier_t root; + for (int k = 0; k < arcflow_paths_supported; ++k) { + root.node[k] = model.path_start[k]; + } + std::sort(root.node.begin(), root.node.end()); + std::vector current{root}; + + std::vector candidates; + std::vector next; + std::vector parents; + for (int t = 0; t < n_tokens; ++t) { + if (preemption_flag.load()) { return std::nullopt; } + const int label = tokens[t]; + const auto arc_begin = model.arcs.begin() + model.arc_offset[label]; + const auto arc_end = model.arcs.begin() + model.arc_offset[label + 1]; + + candidates.clear(); + candidates.reserve(current.size() * arcflow_paths_supported); + for (int i = 0; i < (int)current.size(); ++i) { + const frontier_t& entry = current[i]; + for (int k = 0; k < arcflow_paths_supported; ++k) { + const int node = entry.node[k]; + auto it = std::lower_bound( + arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); + for (; it != arc_end && it->from == node; ++it) { + candidate_t candidate; + candidate.front = entry; + candidate.front.node[k] = it->to; + std::sort(candidate.front.node.begin(), candidate.front.node.end()); + candidate.front.cost = entry.cost + it->cost; + candidate.parent = parent_t{i, (int)(it - model.arcs.begin())}; + candidates.push_back(candidate); + } + } + } + if (candidates.empty()) { return std::nullopt; } + result.peak_raw = std::max(result.peak_raw, candidates.size()); + + // A total order over the whole record keeps the surviving representative of a state + // independent of the enumeration order, so the result is reproducible run to run. + std::sort(candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { + if (!state_equal(a.front, b.front)) { return state_less(a.front, b.front); } + if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } + if (a.parent.prev != b.parent.prev) { return a.parent.prev < b.parent.prev; } + return a.parent.arc < b.parent.arc; + }); + candidates.erase(std::unique(candidates.begin(), + candidates.end(), + [](const candidate_t& a, const candidate_t& b) { + return state_equal(a.front, b.front); + }), + candidates.end()); + + const size_t remaining = + arcflow_history_bytes_max > retained_bytes ? arcflow_history_bytes_max - retained_bytes : 0; + const size_t affordable = std::max(remaining / sizeof(parent_t), 1); + if (candidates.size() > affordable) { + std::stable_sort( + candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { + if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } + return state_less(a.front, b.front); + }); + candidates.resize(affordable); + std::sort( + candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { + return state_less(a.front, b.front); + }); + result.exact = false; + } + + next.clear(); + parents.clear(); + next.reserve(candidates.size()); + parents.reserve(candidates.size()); + for (const auto& candidate : candidates) { + next.push_back(candidate.front); + parents.push_back(candidate.parent); + } + retained_bytes += parents.size() * sizeof(parent_t); + result.peak_kept = std::max(result.peak_kept, next.size()); + history.push_back(std::move(parents)); + current.swap(next); + } + + // Close every frontier, respecting how many paths a node may absorb. + int best_index = -1; + double best_total = std::numeric_limits::infinity(); + for (int i = 0; i < (int)current.size(); ++i) { + const frontier_t& entry = current[i]; + double total = entry.cost; + bool closable = true; + for (int k = 0; k < arcflow_paths_supported && closable; ++k) { + const int node = entry.node[k]; + int64_t sharing = 0; + for (int q = 0; q < arcflow_paths_supported; ++q) { + if (entry.node[q] == node) { sharing++; } + } + if (model.terminator_capacity[node] < sharing) { + closable = false; + } else { + total += model.terminator_cost[node]; + } + } + if (closable && total < best_total) { + best_total = total; + best_index = i; + } + } + if (best_index < 0) { return std::nullopt; } + + result.cost = best_total; + for (int k = 0; k < arcflow_paths_supported; ++k) { + const int col = model.terminator_col[current[best_index].node[k]]; + if (col >= 0) { result.columns.push_back(col); } + } + int index = best_index; + for (int t = n_tokens; t > 0; --t) { + const parent_t& step = history[t - 1][index]; + cuopt_assert(step.arc >= 0, "every level beyond the root records the arc it consumed"); + result.columns.push_back(model.arcs[step.arc].col); + index = step.prev; + } + cuopt_assert(index == 0, "reconstruction must terminate at the root state"); + return result; +} + +} // namespace + +// --------------------------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------------------------- + +template +struct arc_flow_t::host_state_t { + host_problem_t h; +}; + +template +arc_flow_t::arc_flow_t() = default; + +template +arc_flow_t::~arc_flow_t() = default; + +// A model that passes keeps its host mirror, which is what solve() then reads. Each gate fetches +// only what it reads, and the transpose is built once the model is accepted: a declining model pays +// for the row bounds and one pass over the matrix, nothing more. Each fetch is issued as a batch +// and waited on once. +template +bool arc_flow_t::recognize(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t&) +{ + const i_t n_variables = op_problem.get_n_variables(); + const i_t n_constraints = op_problem.get_n_constraints(); + if (!arcflow_accepts_shape(n_variables, n_constraints, op_problem.get_nnz())) { return false; } + if (op_problem.get_n_integers() != n_variables) { return false; } + + auto stream = op_problem.get_handle_ptr()->get_stream(); + const auto& d_row_lb = op_problem.get_constraint_lower_bounds(); + const auto& d_row_ub = op_problem.get_constraint_upper_bounds(); + if ((i_t)d_row_lb.size() != n_constraints || (i_t)d_row_ub.size() != n_constraints) { + return false; + } + std::vector row_lb(n_constraints); + std::vector row_ub(n_constraints); + raft::copy(row_lb.data(), d_row_lb.data(), row_lb.size(), stream); + raft::copy(row_ub.data(), d_row_ub.data(), row_ub.size(), stream); + stream.synchronize(); + if (!arcflow_accepts_bounds(row_lb, row_ub)) { return false; } + + const auto& d_values = op_problem.get_constraint_matrix_values(); + const auto& d_indices = op_problem.get_constraint_matrix_indices(); + const auto& d_offsets = op_problem.get_constraint_matrix_offsets(); + if ((i_t)d_offsets.size() != n_constraints + 1) { return false; } + if (d_values.size() != d_indices.size()) { return false; } + std::vector values(d_values.size()); + std::vector indices(d_indices.size()); + std::vector offsets(d_offsets.size()); + raft::copy(values.data(), d_values.data(), values.size(), stream); + raft::copy(indices.data(), d_indices.data(), indices.size(), stream); + raft::copy(offsets.data(), d_offsets.data(), offsets.size(), stream); + stream.synchronize(); + + const arcflow_tol_t tol = structural_tolerance(); + const auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); + if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } + + const auto& d_obj = op_problem.get_objective_coefficients(); + const auto& d_var_lb = op_problem.get_variable_lower_bounds(); + const auto& d_var_ub = op_problem.get_variable_upper_bounds(); + const auto& d_var_types = op_problem.get_variable_types(); + cuopt_assert((i_t)d_obj.size() == n_variables, "Size mismatch"); + cuopt_assert((i_t)d_var_lb.size() == n_variables, "Size mismatch"); + cuopt_assert((i_t)d_var_ub.size() == n_variables, "Size mismatch"); + cuopt_assert((i_t)d_var_types.size() == n_variables, "Size mismatch"); + + auto state = std::make_unique(); + state->h.n_variables = n_variables; + state->h.n_constraints = n_constraints; + state->h.tol = tol; + state->h.row_lb = std::move(row_lb); + state->h.row_ub = std::move(row_ub); + state->h.obj.resize(d_obj.size()); + state->h.var_lb.resize(d_var_lb.size()); + state->h.var_ub.resize(d_var_ub.size()); + state->h.var_types.resize(d_var_types.size()); + raft::copy(state->h.obj.data(), d_obj.data(), state->h.obj.size(), stream); + raft::copy(state->h.var_lb.data(), d_var_lb.data(), state->h.var_lb.size(), stream); + raft::copy(state->h.var_ub.data(), d_var_ub.data(), state->h.var_ub.size(), stream); + raft::copy(state->h.var_types.data(), d_var_types.data(), state->h.var_types.size(), stream); + stream.synchronize(); + transpose_into_csc(values, indices, offsets, state->h); + + state_ = std::move(state); + return true; +} + +// The root position hands over the fully reduced problem, which already carries the column major +// view this detector wants, so the gates read it directly and no transpose is needed at all. +template +bool arc_flow_t::recognize(const problem_t& problem, + const typename mip_solver_settings_t::tolerances_t&) +{ + const i_t n_variables = problem.n_variables; + const i_t n_constraints = problem.n_constraints; + if (!arcflow_accepts_shape(n_variables, n_constraints, problem.nnz)) { return false; } + if (problem.n_integer_vars != n_variables) { return false; } + + auto stream = problem.handle_ptr->get_stream(); + cuopt_assert((i_t)problem.constraint_lower_bounds.size() == n_constraints, "Size mismatch"); + cuopt_assert((i_t)problem.constraint_upper_bounds.size() == n_constraints, "Size mismatch"); + std::vector row_lb(n_constraints); + std::vector row_ub(n_constraints); + raft::copy(row_lb.data(), problem.constraint_lower_bounds.data(), row_lb.size(), stream); + raft::copy(row_ub.data(), problem.constraint_upper_bounds.data(), row_ub.size(), stream); + stream.synchronize(); + if (!arcflow_accepts_bounds(row_lb, row_ub)) { return false; } + + std::vector csc_values(problem.reverse_coefficients.size()); + std::vector csc_rows(problem.reverse_constraints.size()); + std::vector csc_offsets(problem.reverse_offsets.size()); + raft::copy(csc_values.data(), problem.reverse_coefficients.data(), csc_values.size(), stream); + raft::copy(csc_rows.data(), problem.reverse_constraints.data(), csc_rows.size(), stream); + raft::copy(csc_offsets.data(), problem.reverse_offsets.data(), csc_offsets.size(), stream); + stream.synchronize(); + cuopt_assert((i_t)csc_offsets.size() == n_variables + 1, "Size mismatch"); + cuopt_assert(csc_values.size() == csc_rows.size(), "Size mismatch"); + + const arcflow_tol_t tol = structural_tolerance(); + const auto profile = + profile_from_csc(n_variables, n_constraints, csc_values, csc_rows, csc_offsets); + if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } + + cuopt_assert((i_t)problem.objective_coefficients.size() == n_variables, "Size mismatch"); + cuopt_assert((i_t)problem.variable_types.size() == n_variables, "Size mismatch"); + + auto state = std::make_unique(); + state->h.n_variables = n_variables; + state->h.n_constraints = n_constraints; + state->h.tol = tol; + state->h.csc_values = std::move(csc_values); + state->h.csc_rows = std::move(csc_rows); + state->h.csc_offsets = std::move(csc_offsets); + state->h.row_lb = std::move(row_lb); + state->h.row_ub = std::move(row_ub); + state->h.obj.resize(problem.objective_coefficients.size()); + state->h.var_types.resize(problem.variable_types.size()); + raft::copy( + state->h.obj.data(), problem.objective_coefficients.data(), state->h.obj.size(), stream); + raft::copy( + state->h.var_types.data(), problem.variable_types.data(), state->h.var_types.size(), stream); + stream.synchronize(); + std::tie(state->h.var_lb, state->h.var_ub) = + cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); + + state_ = std::move(state); + return true; +} + +template +structural_outcome_t arc_flow_t::solve( + const typename mip_solver_settings_t::tolerances_t&, + std::atomic& preemption, + double, + std::vector& assignment) +{ + cuopt_assert(state_ != nullptr, "solve called without a successful recognize"); + const auto& h = state_->h; + + std::vector rows; + if (!classify_rows(h, rows)) { + CUOPT_LOG_DEBUG("[ArcFlow] rejected: rows are not unit incidence after normalization"); + return structural_outcome_t::declined; + } + + arc_flow_model_t model; + if (!build_structure(h, rows, model)) { + CUOPT_LOG_DEBUG("[ArcFlow] rejected: columns do not match the labelled arc pattern"); + return structural_outcome_t::declined; + } + if (preemption.load()) { return structural_outcome_t::declined; } + + if (!derive_potential(model, preemption)) { + CUOPT_LOG_DEBUG("[ArcFlow] rejected: no consistent potential and affine cost model"); + return structural_outcome_t::declined; + } + if (preemption.load()) { return structural_outcome_t::declined; } + + bool ordering_exact = true; + const auto tokens = token_order(model, ordering_exact); + CUOPT_LOG_DEBUG("[ArcFlow] detected %d nodes, %d labels, %d paths, %zu tokens, ordering %s", + model.n_nodes, + model.n_labels, + arcflow_paths_supported, + tokens.size(), + ordering_exact ? "identified" : "partly by reachability"); + + const auto result = run_dp(model, tokens, preemption); + if (!result.has_value()) { + CUOPT_LOG_DEBUG("[ArcFlow] no complete path set found in the ordered family"); + return structural_outcome_t::declined; + } + // Exact means exact for what was searched: the dynamic program is optimal over the token order it + // was given, so an order that was not fully identified makes the result heuristic however + // complete the search of it was. + search_was_exact_ = result->exact && ordering_exact; + CUOPT_LOG_DEBUG("[ArcFlow] search %s, peak width %zu raw and %zu retained", + result->exact ? "exact" : "beamed by the history budget", + result->peak_raw, + result->peak_kept); + + assignment.assign((size_t)h.n_variables, f_t{0}); + for (int col : result->columns) { + assignment[col] += f_t{1}; + } + return structural_outcome_t::constructed; +} + +#if MIP_INSTANTIATE_FLOAT +template class arc_flow_t; +#endif + +#if MIP_INSTANTIATE_DOUBLE +template class arc_flow_t; +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cuh b/cpp/src/mip_heuristics/structural/arc_flow.cuh new file mode 100644 index 0000000000..0d938b5cf2 --- /dev/null +++ b/cpp/src/mip_heuristics/structural/arc_flow.cuh @@ -0,0 +1,68 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include + +namespace cuopt::mathematical_optimization::mip { + +// Constructive primal heuristic for enhanced arc-flow models: m source-to-sink paths through a +// DAG whose internal arcs each carry one covering-row label, in the Valerio de Carvalho arc-flow +// lineage. It consumes the covering demands as an ordered token sequence and solves the resulting +// frontier dynamic program, which is optimal over that sequence unless the reconstruction history +// exceeds its budget and forces a beam. +// +// The recognized family is narrower than arc-flow in general. The node potential and the token +// order are both recovered from the objective, so an arc's cost must be affine in the potential of +// its tail, and a negative fitted slope is rejected. Fitting a slope needs two arcs at distinct +// potentials: a label that has fewer is placed by reachability instead, which is a position its arc +// set determines but not the one weighted shortest processing time would give, so the sequence +// searched is no longer the Smith-ordered one. Path termination is recognized in either of its +// encodings, an explicit unlabelled arc or slack in the node's conservation row, which is what lets +// the same detector run before and after a presolve pass that substitutes bounded singleton columns +// out of their equality. Other presolve reductions, notably row aggregation and coefficient +// strengthening, destroy the pattern and are not handled. +// +// Detection reads only permutation-invariant data: row bounds, coefficient patterns, the +// right-hand side, and statistics derived from the arc set. Row order, column order and names are +// never consulted, so detection and the published objective are invariant under a permutation of +// the model. The selected support is not, and cannot be where the model has automorphisms. +template +class arc_flow_t : public structural_heuristic_t { + public: + arc_flow_t(); + ~arc_flow_t() override; + + const char* name() const override { return "ArcFlowDP"; } + + bool recognize(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) override; + bool recognize(const problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) override; + + structural_outcome_t solve( + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + double work_budget, + std::vector& assignment) override; + + // True only when the constructed point is the optimum of the Smith-ordered family: false if the + // history budget forced a beam, and false if any label had to be ordered by reachability because + // its slope could not be fitted. + bool search_was_exact() const { return search_was_exact_; } + + private: + // Host mirror recovered by recognize(), consumed by solve(). Defined in the source, since its + // shape is the detector's business alone. + struct host_state_t; + + std::unique_ptr state_; + bool search_was_exact_{true}; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/structural/early_structural.cu b/cpp/src/mip_heuristics/structural/early_structural.cu new file mode 100644 index 0000000000..34c6bced18 --- /dev/null +++ b/cpp/src/mip_heuristics/structural/early_structural.cu @@ -0,0 +1,273 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "early_structural.cuh" + +#include +#include + +#include +#include + +#include + +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +namespace { + +// Recomputed from the solver-space problem rather than from whatever structure a heuristic thinks +// it found, so a detection mistake cannot publish an infeasible point. Feasibility is decided by +// the solver's own tolerances: the question is whether the solver would accept this point. +template +bool validate(const problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + const std::vector& assignment, + f_t& objective) +{ + auto stream = problem.handle_ptr->get_stream(); + + const auto csr_values = cuopt::host_copy(problem.coefficients, stream); + const auto csr_cols = cuopt::host_copy(problem.variables, stream); + const auto csr_offsets = cuopt::host_copy(problem.offsets, stream); + const auto row_lb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + const auto row_ub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + const auto obj = cuopt::host_copy(problem.objective_coefficients, stream); + const auto var_types = cuopt::host_copy(problem.variable_types, stream); + const auto [var_lb, var_ub] = + cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); + + if ((i_t)assignment.size() != problem.n_variables) { return false; } + + const double integrality = tolerances.integrality_tolerance; + const double abs_tol = tolerances.absolute_tolerance; + const double rel_tol = tolerances.relative_tolerance; + + double obj_value = 0.0; + for (i_t j = 0; j < problem.n_variables; ++j) { + const double x = assignment[j]; + if (var_types[j] == var_t::INTEGER && std::abs(x - std::round(x)) > integrality) { + return false; + } + if (x < (double)var_lb[j] - abs_tol || x > (double)var_ub[j] + abs_tol) { return false; } + obj_value += obj[j] * x; + } + + for (i_t r = 0; r < problem.n_constraints; ++r) { + double activity = 0.0; + for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { + activity += (double)csr_values[k] * (double)assignment[csr_cols[k]]; + } + const double slack = abs_tol + rel_tol * std::max(1.0, std::abs(activity)); + const double lo = row_lb[r]; + const double hi = row_ub[r]; + if (std::isfinite(lo) && activity < lo - slack) { return false; } + if (std::isfinite(hi) && activity > hi + slack) { return false; } + } + + objective = obj_value; + return true; +} + +} // namespace + +template +early_structural_t::early_structural_t( + const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback) + : early_heuristic_t>(std::move(incumbent_callback)), + op_problem_(op_problem), + tolerances_(tolerances) +{ + auto arc_flow = std::make_unique>(); + if (arc_flow->recognize(op_problem, tolerances)) { active_ = std::move(arc_flow); } + + // The framework's problem copy is the expensive part of construction, so it is built only once a + // structure has been recognized. + if (active_) { + active_->set_lane_budget(omp_get_num_threads() - 1); + CUOPT_LOG_DEBUG("[Early Structural] %s recognized the model", active_->name()); + this->initialize_problem(op_problem, tolerances); + } +} + +template +early_structural_t::~early_structural_t() +{ + stop(); +} + +template +const char* early_structural_t::recognized_name() const +{ + return active_ ? active_->name() : nullptr; +} + +template +void early_structural_t::start() +{ + if (!active_ || task_launched_ || + omp_get_num_threads() < CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT) { + return; + } + + preemption_flag_.store(false); + this->start_time_ = std::chrono::steady_clock::now(); + task_launched_ = true; + + // A data member is not a valid depend list item, so the dependence is named through a + // dereferenced pointer to it. stop() names the same storage, which is what pairs the taskwait + // with this task. + auto* task_token = &preemption_flag_; + CUOPT_LOG_DEBUG("Launching early structural task for %s", active_->name()); +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) depend(out : *task_token) + this->run(); +} + +template +void early_structural_t::stop() +{ + if (!task_launched_) { return; } + + auto* task_token = &preemption_flag_; + preemption_flag_.store(true); +#pragma omp taskwait depend(in : *task_token) + task_launched_ = false; + + CUOPT_LOG_DEBUG("[Early Structural] Stopped, solution_found=%d", (int)this->solution_found_); +} + +// An assignment built in op_problem space can only be published through post_process_assignment if +// preprocessing left the columns where they were: same count, no shifted lower bound, no split. +template +bool early_structural_t::preprocessing_is_identity() const +{ + const auto& presolve_data = this->problem_ptr_->presolve_data; + if (this->problem_ptr_->n_variables != op_problem_.get_n_variables()) { return false; } + if ((i_t)presolve_data.variable_offsets.size() != this->problem_ptr_->n_variables) { + return false; + } + for (const f_t offset : presolve_data.variable_offsets) { + if (offset != f_t{0}) { return false; } + } + for (size_t j = 0; j < presolve_data.additional_var_used.size(); ++j) { + if (presolve_data.additional_var_used[j]) { return false; } + } + return true; +} + +template +void early_structural_t::run() +{ + cuopt_assert(active_ != nullptr, "task launched without a recognized structure"); + + std::vector assignment; + const structural_outcome_t outcome = + active_->solve(tolerances_, preemption_flag_, 0.0, assignment); + if (outcome != structural_outcome_t::constructed) { + CUOPT_LOG_DEBUG("[Early Structural] %s constructed nothing", active_->name()); + return; + } + if (preemption_flag_.load()) { return; } + + // Checked before validation rather than after: without column identity the assignment cannot be + // read in solver space at all, which is the space both the validator and the publication use. + if (!preprocessing_is_identity()) { + CUOPT_LOG_DEBUG( + "[Early Structural] %s constructed a point but preprocessing moved the columns, discarding", + active_->name()); + return; + } + + f_t objective{0}; + if (!validate(*this->problem_ptr_, tolerances_, assignment, objective)) { + CUOPT_LOG_DEBUG("[Early Structural] %s constructed a point that failed validation, discarding", + active_->name()); + return; + } + this->try_update_best(objective, assignment, active_->name()); +} + +template +root_structural_t::root_structural_t( + problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + const std::vector>* column_symmetry, + int lane_budget) + : problem_(problem), tolerances_(tolerances), preemption_(preemption) +{ + auto arc_flow = std::make_unique>(); + if (arc_flow->recognize(problem, tolerances)) { active_ = std::move(arc_flow); } + if (active_) { + active_->set_lane_budget(lane_budget); + active_->set_column_symmetry(column_symmetry); + CUOPT_LOG_DEBUG("[Root Structural] %s recognized the model, %d lanes, %zu symmetry generators", + active_->name(), + lane_budget, + column_symmetry == nullptr ? size_t{0} : column_symmetry->size()); + } +} + +template +root_structural_t::~root_structural_t() = default; + +template +const char* root_structural_t::recognized_name() const +{ + return active_ ? active_->name() : nullptr; +} + +template +void root_structural_t::run() +{ + if (!active_) { return; } + if (!problem_.branch_and_bound_callback) { + CUOPT_LOG_DEBUG("[Root Structural] no branch and bound to publish to, skipping"); + return; + } + + // The recognizer read this problem, so the point comes back in the space B&B branches in and + // needs no mapping. It is still validated: a detection mistake must not reach the tree. + std::vector assignment; + const structural_outcome_t outcome = active_->solve(tolerances_, preemption_, 0.0, assignment); + if (outcome != structural_outcome_t::constructed) { + CUOPT_LOG_DEBUG("[Root Structural] %s constructed nothing", active_->name()); + return; + } + if (preemption_.load()) { return; } + + f_t objective{0}; + if (!validate(problem_, tolerances_, assignment, objective)) { + CUOPT_LOG_DEBUG("[Root Structural] %s constructed a point that failed validation, discarding", + active_->name()); + return; + } + + const bool accepted = + problem_.branch_and_bound_callback(assignment, heuristics_origin_t::HEURISTICS); + CUOPT_LOG_DEBUG("[Root Structural] %s published objective %+.6e, accepted=%d", + active_->name(), + (double)problem_.get_user_obj_from_solver_obj(objective), + (int)accepted); +} + +#if MIP_INSTANTIATE_FLOAT +template class early_structural_t; +template class root_structural_t; +#endif + +#if MIP_INSTANTIATE_DOUBLE +template class early_structural_t; +template class root_structural_t; +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/structural/early_structural.cuh b/cpp/src/mip_heuristics/structural/early_structural.cuh new file mode 100644 index 0000000000..bdebe0f642 --- /dev/null +++ b/cpp/src/mip_heuristics/structural/early_structural.cuh @@ -0,0 +1,149 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include + +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +enum class structural_outcome_t : uint8_t { declined, constructed, budget_exhausted }; + +// A primal heuristic for one recognizable model structure. Recognition and construction both live +// here: the pass owns everything from reading the model to producing a point, and the dispatcher +// owns the framework hook, the validation and the publication. +// +// Subclasses must be default constructible and cheap to construct, because the dispatcher builds +// one before it knows whether the model matches. All real work belongs in recognize() and solve(). +template +class structural_heuristic_t { + public: + virtual ~structural_heuristic_t() = default; + + virtual const char* name() const = 0; + + // Necessary conditions on the model, cheap enough to run on the solve's thread before any GPU + // work is committed. Non-const so a subclass can keep the host view it built here for solve(). + // The tolerances decide every comparison the detector makes, so they belong here rather than only + // in solve(): a view built against one set and read against another proves nothing. + virtual bool recognize( + const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) = 0; + + // The root position hands over the fully reduced problem instead. Declining by default lets a + // heuristic take one position without implementing the other. + virtual bool recognize(const problem_t&, + const typename mip_solver_settings_t::tolerances_t&) + { + return false; + } + + // Full detection and construction over the view recognize() kept, which is why the model is not a + // parameter: it came from whichever source recognized it, and the assignment comes back indexed + // in that source's columns. A zero work_budget means unlimited. The preemption flag is not + // const because a sub-solver may need to bind it by reference; a subclass must only ever read it. + virtual structural_outcome_t solve( + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + double work_budget, + std::vector& assignment) = 0; + + // How many threads the heuristic may use inside solve(). Only the position that launches it + // knows what else is sharing the team, so it is told rather than asking. + void set_lane_budget(int lanes) { lane_budget_ = lanes; } + + // Dense column permutations of the model, one per generator of its symmetry group, or null when + // none are known. Kept as plain permutations so a heuristic never depends on whatever computed + // them. The pointee must outlive solve(). + void set_column_symmetry(const std::vector>* generators) + { + column_symmetry_ = generators; + } + + protected: + int lane_budget() const { return lane_budget_; } + const std::vector>* column_symmetry() const { return column_symmetry_; } + + private: + int lane_budget_{1}; + const std::vector>* column_symmetry_{nullptr}; +}; + +// Runs whichever structural heuristic recognizes the model, on one task during presolve. Nothing +// is asked of the call site beyond construction: when no structure is recognized the object stays +// inert, having skipped the framework's problem copy entirely, and start() does nothing. +template +class early_structural_t : public early_heuristic_t> { + public: + // op_problem must outlive this object: the publication gate reads its column count. + early_structural_t(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback); + + ~early_structural_t(); + + static constexpr const char* name() { return "Structural"; } + + // Name of the heuristic that recognized the model, or nullptr when none did. + const char* recognized_name() const; + + void start(); + void stop(); + + private: + // Body of the task: solve, validate, publish. + void run(); + + // True when preprocessing left the column space of problem_ptr_ identical to op_problem's, which + // is what lets an assignment built in op_problem space be published through post_process. + bool preprocessing_is_identity() const; + + const optimization_problem_t& op_problem_; + typename mip_solver_settings_t::tolerances_t tolerances_; + std::unique_ptr> active_; + std::atomic preemption_flag_{false}; + bool task_launched_{false}; +}; + +// The same heuristics run again from the root, on the fully reduced problem B&B itself solves. +// Nothing about the early positions carries over: there is no problem copy, no private handle and +// no preprocessing gate, because the point produced here is already in the space it is published +// in. Its window is the root relaxation and the cut loop, during which B&B's worker pools do not +// yet exist, so the threads they will later claim are free to use. +template +class root_structural_t { + public: + // problem, preemption and column_symmetry must outlive this object: run() reads them from inside + // the task. column_symmetry may be null when the solve found none. + root_structural_t(problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + const std::vector>* column_symmetry, + int lane_budget); + + ~root_structural_t(); + + // Name of the heuristic that recognized the model, or nullptr when none did. + const char* recognized_name() const; + + bool recognized() const { return active_ != nullptr; } + + // Detect, construct, validate and hand the point to B&B. Blocking: the caller supplies the task. + void run(); + + private: + problem_t& problem_; + typename mip_solver_settings_t::tolerances_t tolerances_; + std::atomic& preemption_; + std::unique_ptr> active_; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/tests/internal/CMakeLists.txt b/cpp/tests/internal/CMakeLists.txt index 2856c56f38..e7abeac96f 100644 --- a/cpp/tests/internal/CMakeLists.txt +++ b/cpp/tests/internal/CMakeLists.txt @@ -28,6 +28,7 @@ ConfigureTest(NUMOPT_INTERNAL_TEST ${CUOPT_TEST_DIR}/mip/empty_fixed_problems_test.cu ${CUOPT_TEST_DIR}/mip/presolve_test.cu ${CUOPT_TEST_DIR}/mip/gf2_presolve_test.cpp + ${CUOPT_TEST_DIR}/mip/arc_flow_test.cu ${CUOPT_TEST_DIR}/mip/termination_test.cu ${CUOPT_TEST_DIR}/mip/determinism_test.cu # socp diff --git a/cpp/tests/mip/arc_flow_test.cu b/cpp/tests/mip/arc_flow_test.cu new file mode 100644 index 0000000000..8220f04430 --- /dev/null +++ b/cpp/tests/mip/arc_flow_test.cu @@ -0,0 +1,537 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include +#include + +#include + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::test { + +namespace { + +// Enhanced arc-flow model of two identical parallel machines minimizing weighted completion time. +// A job arc advances one machine's clock from state q to q + p, costs w * q, and covers one unit +// of its job type's demand; a loss arc pads the tail of a machine's horizon. +struct job_type_t { + int p; + int w; + int d; +}; + +struct built_model_t { + std::vector values; + std::vector indices; + std::vector offsets; + std::vector row_lb; + std::vector row_ub; + std::vector obj; + std::vector var_lb; + std::vector var_ub; + std::vector var_types; + int n_rows{0}; + int n_cols{0}; + int horizon{0}; + int loss_first{0}; + int job_arcs{0}; // arcs surviving the reduction + int used_states{0}; // states surviving the reduction +}; + +struct build_options_t { + // Encode path termination as slack in the conservation row instead of an explicit loss arc, + // which is what Papilo's singleton column substitution produces. + bool row_slack_terminators{false}; + bool permute{false}; + double flow_row_factor{1.0}; // scale the second flow row and its bounds + // Break the affine cost model by moving one of this type's interior arcs off its own line. Named + // by type rather than by column because which arcs survive the reduction is not obvious outside + // the builder. + int perturbed_cost_type{-1}; +}; + +constexpr int n_machines = 2; + +int eaf_horizon(const std::vector& jobs) +{ + int total = 0; + int p_max = 0; + for (const auto& job : jobs) { + total += job.p * job.d; + p_max = std::max(p_max, job.p); + } + return (total + (n_machines - 1) * p_max) / n_machines; +} + +int eaf_loss_first(const std::vector& jobs) +{ + int p_max = 0; + for (const auto& job : jobs) { + p_max = std::max(p_max, job.p); + } + return eaf_horizon(jobs) - p_max; +} + +// Types in Smith order: decreasing weight over processing time, ties by index so the reduction is +// deterministic. An optimal schedule runs each machine's jobs in this order, so a path through the +// graph visits types in this order and no other sequence needs representing. +std::vector smith_order(const std::vector& jobs) +{ + std::vector order(jobs.size()); + std::iota(order.begin(), order.end(), 0); + std::stable_sort(order.begin(), order.end(), [&](int a, int b) { + const long lhs = (long)jobs[a].w * jobs[b].p; + const long rhs = (long)jobs[b].w * jobs[a].p; + if (lhs != rhs) { return lhs > rhs; } + return a < b; + }); + return order; +} + +// The reduced graph Kramer, Dell'Amico and Iori build rather than the straight one. A type may +// only leave a state that a canonical path reaches, meaning one composed of types no later in Smith +// order, which drops both arcs and whole states. Straight arc flow gives every type an arc at +// every feasible start, so the load table alone determines reachability and a search that ignored +// the graph would still pass; here it cannot. +struct reduced_graph_t { + std::vector> arcs; // (type, start state) + std::vector states; // used states, ascending + std::vector row_of_state; // state -> row, or -1 when the reduction dropped it +}; + +// A machine may be loaded to exactly the horizon, so the states run to it inclusive. Stopping one +// short silently drops the schedules that fill a machine, which are optimal often enough that the +// graph would no longer contain the optimum for the reference to be compared against. +reduced_graph_t reduce_eaf(const std::vector& jobs, int horizon, int loss_first) +{ + const int last = horizon; + std::vector reachable(last + 1, 0); + reachable[0] = 1; + + reduced_graph_t graph; + for (const int type : smith_order(jobs)) { + const int p = jobs[type].p; + // Copies of this type may precede an arc of it, so its own chains extend reachability first. + const std::vector before = reachable; + for (int q = 0; q <= last; ++q) { + if (!before[q]) { continue; } + for (int copies = 1; copies <= jobs[type].d; ++copies) { + const int target = q + copies * p; + if (target > last) { break; } + reachable[target] = 1; + } + } + for (int q = 0; q + p <= last; ++q) { + if (reachable[q]) { graph.arcs.push_back({type, q}); } + } + } + + std::vector used(last + 1, 0); + used[0] = 1; + for (const auto& [type, start] : graph.arcs) { + used[start] = 1; + used[start + jobs[type].p] = 1; + } + for (int q = loss_first; q <= last; ++q) { + if (reachable[q]) { used[q] = 1; } + } + graph.row_of_state.assign(last + 1, -1); + for (int q = 0; q <= last; ++q) { + if (!used[q]) { continue; } + graph.row_of_state[q] = graph.states.size(); + graph.states.push_back(q); + } + return graph; +} + +built_model_t build_eaf(const std::vector& jobs, const build_options_t& opts) +{ + const int horizon = eaf_horizon(jobs); + const int loss_first = eaf_loss_first(jobs); + EXPECT_GT(loss_first, 0) << "the source state must not also be a terminator"; + + const int n_types = jobs.size(); + const reduced_graph_t graph = reduce_eaf(jobs, horizon, loss_first); + const int n_states = graph.states.size(); + const int n_rows = n_states + n_types; + const auto row_of = [&](int state) { + const int row = graph.row_of_state[state]; + EXPECT_GE(row, 0) << "an arc referenced a state the reduction dropped"; + return row; + }; + + // Columns: every surviving job arc, then the loss arcs when they are represented explicitly. + struct column_t { + std::vector> entries; + double cost; + double ub; + int type{-1}; + }; + std::vector columns; + for (const auto& [type, start] : graph.arcs) { + column_t col; + col.entries = { + {row_of(start), 1.0}, {row_of(start + jobs[type].p), -1.0}, {n_states + type, 1.0}}; + col.cost = (double)jobs[type].w * start; + col.ub = jobs[type].d; + col.type = type; + columns.push_back(std::move(col)); + } + if (!opts.row_slack_terminators) { + for (const int q : graph.states) { + if (q >= loss_first) { columns.push_back(column_t{{{row_of(q), 1.0}}, 0.0, 1.0, -1}); } + } + } + + std::vector row_lb(n_rows, 0.0); + std::vector row_ub(n_rows, 0.0); + row_lb[row_of(0)] = row_ub[row_of(0)] = n_machines; + if (opts.row_slack_terminators) { + for (const int q : graph.states) { + if (q < loss_first) { continue; } + row_lb[row_of(q)] = -1.0; + row_ub[row_of(q)] = 0.0; + } + } + for (int j = 0; j < n_types; ++j) { + row_lb[n_states + j] = jobs[j].d; + row_ub[n_states + j] = std::numeric_limits::infinity(); + } + + if (opts.perturbed_cost_type >= 0) { + std::vector of_type; + for (int c = 0; c < (int)columns.size(); ++c) { + if (columns[c].type == opts.perturbed_cost_type) { of_type.push_back(c); } + } + // The slope is fitted from the label's extreme arcs, so only an interior arc is off the fitted + // line and reachable solely by the residual check over every arc. + EXPECT_GE(of_type.size(), 3u) << "an interior arc needs a type with at least three of them"; + columns[of_type[of_type.size() / 2]].cost += 1.0; + } + + const int n_cols = columns.size(); + std::vector row_perm(n_rows); + std::vector col_perm(n_cols); + std::iota(row_perm.begin(), row_perm.end(), 0); + std::iota(col_perm.begin(), col_perm.end(), 0); + if (opts.permute) { + std::reverse(row_perm.begin(), row_perm.end()); + for (int i = 0; i + 1 < n_cols; i += 2) { + std::swap(col_perm[i], col_perm[i + 1]); + } + } + + built_model_t model; + model.n_rows = n_rows; + model.n_cols = n_cols; + model.horizon = horizon; + model.loss_first = loss_first; + model.job_arcs = graph.arcs.size(); + model.used_states = n_states; + model.obj.assign(n_cols, 0.0); + model.var_lb.assign(n_cols, 0.0); + model.var_ub.assign(n_cols, 0.0); + model.var_types.assign(n_cols, var_t::INTEGER); + model.row_lb.assign(n_rows, 0.0); + model.row_ub.assign(n_rows, 0.0); + + for (int r = 0; r < n_rows; ++r) { + model.row_lb[row_perm[r]] = row_lb[r]; + model.row_ub[row_perm[r]] = row_ub[r]; + } + + std::vector>> by_row(n_rows); + for (int c = 0; c < n_cols; ++c) { + const auto& col = columns[c]; + const int mapped = col_perm[c]; + model.obj[mapped] = col.cost; + model.var_ub[mapped] = col.ub; + for (const auto& [row, value] : col.entries) { + by_row[row_perm[row]].emplace_back(mapped, value); + } + } + if (opts.flow_row_factor != 1.0) { + const int scaled = row_perm[1]; + for (auto& [col, value] : by_row[scaled]) { + value *= opts.flow_row_factor; + } + model.row_lb[scaled] *= opts.flow_row_factor; + model.row_ub[scaled] *= opts.flow_row_factor; + } + + model.offsets.push_back(0); + for (int r = 0; r < n_rows; ++r) { + std::sort(by_row[r].begin(), by_row[r].end()); + for (const auto& [col, value] : by_row[r]) { + model.indices.push_back(col); + model.values.push_back(value); + } + model.offsets.push_back(model.indices.size()); + } + return model; +} + +// Optimal schedule by exhaustive assignment. Smith's rule makes weighted shortest processing +// time optimal per machine, so sequencing each machine that way gives the true optimum. +double brute_force_optimum(const std::vector& jobs) +{ + const int horizon = eaf_horizon(jobs); + const int loss_first = eaf_loss_first(jobs); + std::vector> expanded; // (p, w) + for (const auto& job : jobs) { + for (int i = 0; i < job.d; ++i) { + expanded.emplace_back(job.p, job.w); + } + } + const int n = expanded.size(); + double best = std::numeric_limits::infinity(); + for (int mask = 0; mask < (1 << n); ++mask) { + std::array>, n_machines> machine; + for (int i = 0; i < n; ++i) { + machine[(mask >> i) & 1].push_back(expanded[i]); + } + double cost = 0.0; + bool feasible = true; + for (auto& jobs_on_machine : machine) { + std::stable_sort(jobs_on_machine.begin(), + jobs_on_machine.end(), + [](const std::pair& a, const std::pair& b) { + return (long)a.second * b.first > (long)b.second * a.first; + }); + int clock = 0; + for (const auto& [p, w] : jobs_on_machine) { + cost += (double)w * clock; + clock += p; + } + if (clock < loss_first || clock > horizon) { feasible = false; } + } + if (feasible) { best = std::min(best, cost); } + } + return best; +} + +struct run_outcome_t { + bool prescreened{false}; + bool found{false}; + bool exact{false}; + double objective{0.0}; + std::vector assignment; +}; + +run_outcome_t run_heuristic(const built_model_t& model) +{ + const raft::handle_t handle{}; + optimization_problem_t problem(&handle); + auto values = model.values; + auto indices = model.indices; + auto offsets = model.offsets; + auto obj = model.obj; + auto var_lb = model.var_lb; + auto var_ub = model.var_ub; + auto types = model.var_types; + auto row_lb = model.row_lb; + auto row_ub = model.row_ub; + problem.set_csr_constraint_matrix( + values.data(), values.size(), indices.data(), indices.size(), offsets.data(), offsets.size()); + problem.set_objective_coefficients(obj.data(), obj.size()); + problem.set_variable_lower_bounds(var_lb.data(), var_lb.size()); + problem.set_variable_upper_bounds(var_ub.data(), var_ub.size()); + problem.set_variable_types(types.data(), types.size()); + problem.set_constraint_lower_bounds(row_lb.data(), row_lb.size()); + problem.set_constraint_upper_bounds(row_ub.data(), row_ub.size()); + + mip_solver_settings_t settings; + run_outcome_t outcome; + mip::arc_flow_t heuristic; + outcome.prescreened = heuristic.recognize(problem, settings.get_tolerances()); + if (!outcome.prescreened) { return outcome; } + + std::atomic preemption{false}; + const auto status = + heuristic.solve(settings.get_tolerances(), preemption, 0.0, outcome.assignment); + outcome.found = status == mip::structural_outcome_t::constructed; + outcome.exact = heuristic.search_was_exact(); + // The dispatcher would take this from the solver-space problem; the models here are minimize + // with no offset, so the two agree. + if (outcome.found) { + outcome.objective = 0.0; + for (size_t j = 0; j < outcome.assignment.size(); ++j) { + outcome.objective += model.obj[j] * outcome.assignment[j]; + } + } + return outcome; +} + +const std::vector& small_instance() +{ + static const std::vector jobs = {{1, 3, 2}, {2, 1, 1}, {3, 2, 1}}; + return jobs; +} + +// Processing times that do not tile the horizon, so the reduction leaves gaps: states 1, 3, 8 and +// 13 are unreachable by any canonical path and are absent from the model entirely. +const std::vector& gapped_instance() +{ + static const std::vector jobs = {{2, 9, 2}, {5, 4, 2}, {7, 3, 1}}; + return jobs; +} + +// The heaviest type leads the Smith order and fills six of nine units, so the reduction leaves it a +// single arc out of the source and its cost slope has no second point to be fitted from. +const std::vector& single_arc_label_instance() +{ + static const std::vector jobs = {{2, 1, 1}, {5, 1, 1}, {6, 4, 1}}; + return jobs; +} + +} // namespace + +TEST(arc_flow, matches_brute_force_optimum) +{ + const auto model = build_eaf(small_instance(), {}); + const auto outcome = run_heuristic(model); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); + EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); + // A search this small is nowhere near the history budget, so beaming it would mean the budget + // is being converted into a width up front instead of charged as it accumulates. + EXPECT_TRUE(outcome.exact); +} + +// The reduction is what separates the enhanced graph from the straight one, so the fixture has to +// exercise it: a graph with an arc at every feasible start makes reachability a function of the +// load alone, and a search that never consulted the arc set would pass anyway. +TEST(arc_flow, reduced_graph_omits_states_and_arcs) +{ + const auto model = build_eaf(gapped_instance(), {}); + int straight = 0; + for (const auto& job : gapped_instance()) { + straight += std::max(0, eaf_horizon(gapped_instance()) - job.p + 1); + } + EXPECT_LT(model.job_arcs, straight) << "the reduction dropped no arc"; + EXPECT_LT(model.used_states, model.horizon + 1) << "the reduction dropped no state"; +} + +// Normal patterns keep at least one optimal schedule, so the reduced graph must still reach the +// optimum the reference finds by exhaustive assignment. +TEST(arc_flow, matches_brute_force_optimum_on_reduced_graph) +{ + const auto outcome = run_heuristic(build_eaf(gapped_instance(), {})); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); + EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(gapped_instance())); +} + +// A label with one arc is ordered by where that arc can go rather than by its ratio, which is a +// position the model determines but not the Smith one. The point stays usable; what must not +// happen is the pass reporting it as the optimum of an order it did not actually search. +TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) +{ + const auto outcome = run_heuristic(build_eaf(single_arc_label_instance(), {})); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); + EXPECT_FALSE(outcome.exact); + EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(single_arc_label_instance())); +} + +// The detector reads only permutation invariant data, so reordering rows and columns must not +// change what it finds. This is the property that keeps it from leaning on model index order. +TEST(arc_flow, invariant_under_row_and_column_permutation) +{ + build_options_t permuted; + permuted.permute = true; + const auto plain = run_heuristic(build_eaf(small_instance(), {})); + const auto shuffled = run_heuristic(build_eaf(small_instance(), permuted)); + ASSERT_TRUE(plain.found); + ASSERT_TRUE(shuffled.found); + EXPECT_DOUBLE_EQ(plain.objective, shuffled.objective); +} + +// Papilo substitutes a bounded singleton column out of its equality and leaves the row as an +// inequality, so a loss arc reaches the second early heuristic slot as conservation row slack. +TEST(arc_flow, accepts_row_slack_terminators) +{ + build_options_t slack; + slack.row_slack_terminators = true; + const auto outcome = run_heuristic(build_eaf(small_instance(), slack)); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); + EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); +} + +// MIP scaling applies power of two row factors before this heuristic runs, so the unit incidence +// pattern is only recoverable after normalizing each row by its coefficient magnitude. +TEST(arc_flow, tolerates_row_scaling) +{ + build_options_t scaled; + scaled.flow_row_factor = 4.0; + const auto outcome = run_heuristic(build_eaf(small_instance(), scaled)); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); + EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); +} + +TEST(arc_flow, rejects_non_affine_costs) +{ + // The slope is fitted from the label's extreme arcs, so moving one arc off the line is only + // caught by the residual check that revisits every arc. + build_options_t perturbed; + perturbed.perturbed_cost_type = 1; + const auto outcome = run_heuristic(build_eaf(small_instance(), perturbed)); + EXPECT_FALSE(outcome.found); +} + +// The construction consumes exactly the demanded units, so it cannot discover that oversatisfying +// a covering row pays. A negative cost slope is where that would happen, and the detector has to +// refuse the model rather than return a point it has no argument for. +TEST(arc_flow, rejects_negative_cost_slope) +{ + const std::vector jobs = {{1, 3, 2}, {2, -1, 1}, {3, 2, 1}}; + const auto outcome = run_heuristic(build_eaf(jobs, {})); + EXPECT_FALSE(outcome.found); +} + +TEST(arc_flow, rejects_model_without_unit_incidence) +{ + built_model_t knapsack; + knapsack.n_rows = 1; + knapsack.n_cols = 2; + knapsack.values = {2.0, 3.0}; + knapsack.indices = {0, 1}; + knapsack.offsets = {0, 2}; + knapsack.row_lb = {1.0}; + knapsack.row_ub = {std::numeric_limits::infinity()}; + knapsack.obj = {1.0, 1.0}; + knapsack.var_lb = {0.0, 0.0}; + knapsack.var_ub = {1.0, 1.0}; + knapsack.var_types = {var_t::INTEGER, var_t::INTEGER}; + const auto outcome = run_heuristic(knapsack); + EXPECT_FALSE(outcome.prescreened); + EXPECT_FALSE(outcome.found); +} + +TEST(arc_flow, is_reproducible) +{ + const auto model = build_eaf(small_instance(), {}); + const auto first = run_heuristic(model); + const auto second = run_heuristic(model); + ASSERT_TRUE(first.found); + ASSERT_TRUE(second.found); + EXPECT_DOUBLE_EQ(first.objective, second.objective); + EXPECT_EQ(first.assignment, second.assignment); +} + +} // namespace cuopt::mathematical_optimization::test From 147f4a67bd7ed12cc94d3e032fecd2d84d4ece1a Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 31 Aug 2026 07:13:10 -0700 Subject: [PATCH 2/7] bit of refactorign and cleanup --- cpp/src/mip_heuristics/early_heuristic.cuh | 49 ++-- .../feasibility_jump/early_cpufj.cu | 4 +- .../feasibility_jump/early_gpufj.cu | 4 +- .../mip_heuristics/feasibility_jump/fj_cpu.cu | 174 +++++-------- .../feasibility_jump/fj_cpu.cuh | 27 -- cpp/src/mip_heuristics/mip_constants.hpp | 11 +- cpp/src/mip_heuristics/solve.cu | 22 +- cpp/src/mip_heuristics/solver.cu | 36 +-- cpp/src/mip_heuristics/structural/arc_flow.cu | 243 ++++++------------ .../mip_heuristics/structural/arc_flow.cuh | 26 +- .../structural/early_structural.cu | 132 ++++------ .../structural/early_structural.cuh | 81 ++---- cpp/tests/mip/arc_flow_test.cu | 177 ++++++------- skills/cuopt-developer/SKILL.md | 1 + 14 files changed, 360 insertions(+), 627 deletions(-) diff --git a/cpp/src/mip_heuristics/early_heuristic.cuh b/cpp/src/mip_heuristics/early_heuristic.cuh index 150d27b767..cb0be4200a 100644 --- a/cpp/src/mip_heuristics/early_heuristic.cuh +++ b/cpp/src/mip_heuristics/early_heuristic.cuh @@ -29,15 +29,30 @@ using early_incumbent_callback_t = std::function class early_heuristic_t { public: - explicit early_heuristic_t(early_incumbent_callback_t incumbent_callback) + early_heuristic_t(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback) : incumbent_callback_(std::move(incumbent_callback)) { RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); + + // Build and preprocess on the original handle, then copy onto our own handle + // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). + problem_t temp_problem(op_problem, tolerances, false); + temp_problem.preprocess_problem(); + temp_problem.handle_ptr->sync_stream(); + problem_ptr_ = std::make_unique>(temp_problem, &handle_); + + solution_ptr_ = std::make_unique>(*problem_ptr_); + thrust::fill(handle_.get_thrust_policy(), + solution_ptr_->assignment.begin(), + solution_ptr_->assignment.end(), + f_t{0}); + solution_ptr_->clamp_within_bounds(); } bool solution_found() const { return solution_found_; } @@ -45,7 +60,6 @@ class early_heuristic_t { // Return the best objective converted to user-space (sense-aware, offset-aware). f_t get_best_user_objective() const { - cuopt_assert(problem_ptr_ != nullptr, "initialize_problem was not called"); return problem_ptr_->get_user_obj_from_solver_obj(best_objective_); } // Set the incumbent threshold. `obj` must be in THIS heuristic's solver-space @@ -58,35 +72,12 @@ class early_heuristic_t { protected: ~early_heuristic_t() = default; - // Must run on the thread owning op_problem's handle, and before start(). - void initialize_problem(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t& tolerances) - { - cuopt_assert(problem_ptr_ == nullptr, "initialize_problem called twice"); - - // Build and preprocess on the original handle, then copy onto our own handle - // so the derived solver can run on a dedicated stream (prevents graph capture conflicts). - problem_t temp_problem(op_problem, tolerances, false); - temp_problem.preprocess_problem(); - temp_problem.handle_ptr->sync_stream(); - problem_ptr_ = std::make_unique>(temp_problem, &handle_); - - solution_ptr_ = std::make_unique>(*problem_ptr_); - thrust::fill(handle_.get_thrust_policy(), - solution_ptr_->assignment.begin(), - solution_ptr_->assignment.end(), - f_t{0}); - solution_ptr_->clamp_within_bounds(); - } - // NOT thread-safe. solver_obj is in solver-space (always minimization). // Uses a private CUDA stream to avoid racing with the FJ solver's stream. - // `name` attributes the incumbent to a sub-heuristic; null attributes it to the pass itself. void try_update_best(f_t solver_obj, const std::vector& assignment, - const char* name = nullptr) + const char* heuristic_name = Derived::name()) { - cuopt_assert(problem_ptr_ != nullptr, "initialize_problem was not called"); if (solver_obj >= best_objective_) { return; } best_objective_ = solver_obj; @@ -103,7 +94,7 @@ class early_heuristic_t { // Log and callback are deferred to the shared incumbent_callback_ which enforces // global monotonicity across all early heuristic instances. if (incumbent_callback_) { - incumbent_callback_(solver_obj, user_obj, user_assignment, name ? name : Derived::name()); + incumbent_callback_(solver_obj, user_obj, user_assignment, heuristic_name); } } diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index 1a882ac0f5..ba14e657d5 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -16,9 +16,9 @@ early_cpufj_t::early_cpufj_t( const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t& tolerances, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>(std::move(incumbent_callback)) + : early_heuristic_t>( + op_problem, tolerances, std::move(incumbent_callback)) { - this->initialize_problem(op_problem, tolerances); } template diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu index c1e46ff707..697f26e6df 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu @@ -22,9 +22,9 @@ template early_gpufj_t::early_gpufj_t(const optimization_problem_t& op_problem, const mip_solver_settings_t& settings, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>(std::move(incumbent_callback)) + : early_heuristic_t>( + op_problem, settings.get_tolerances(), std::move(incumbent_callback)) { - this->initialize_problem(op_problem, settings.get_tolerances()); context_ptr_ = std::make_unique>( &this->handle_, this->problem_ptr_.get(), settings); } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index d30839a961..57a6a89479 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -1419,76 +1419,97 @@ void finalize_fj_cpu_host_initialization( } template -std::unique_ptr> init_fj_cpu_from_host_model( - fj_cpu_host_model_t model, +static std::unique_ptr> init_fj_cpu_from_host_lp( + const lp_problem_t& problem, + const std::vector& variable_types, const std::vector& seed_assignment, - const typename mip_solver_settings_t::tolerances_t& tolerances, + const simplex_solver_settings_t& settings, std::atomic& preemption_flag, - fj_settings_t settings) + int64_t seed) { using f_t2 = typename type_2::type; - const i_t n_variables = model.n_variables; - const i_t n_constraints = model.n_constraints; - const i_t nnz = static_cast(model.variables.size()); - cuopt_assert(static_cast(model.offsets.size()) == n_constraints + 1, "offset size mismatch"); - cuopt_assert(model.coefficients.size() == model.variables.size(), "csr size mismatch"); - cuopt_assert(static_cast(model.var_types.size()) == n_variables, + cuopt_assert(variable_types.size() >= static_cast(problem.num_cols), "variable type size mismatch"); - cuopt_assert(static_cast(model.objective.size()) == n_variables, "objective size mismatch"); - cuopt_assert(static_cast(model.var_lb.size()) == n_variables && - static_cast(model.var_ub.size()) == n_variables, - "variable bound size mismatch"); - cuopt_assert(static_cast(model.row_lb.size()) == n_constraints && - static_cast(model.row_ub.size()) == n_constraints, - "row bound size mismatch"); + typename mip_solver_settings_t::tolerances_t tolerances{}; + tolerances.absolute_tolerance = settings.primal_tol; + tolerances.relative_tolerance = settings.zero_tol; + tolerances.integrality_tolerance = settings.integer_tol; + tolerances.absolute_mip_gap = settings.absolute_mip_gap_tol; + tolerances.relative_mip_gap = settings.relative_mip_gap_tol; + + const i_t n_variables = problem.num_cols; + const i_t n_constraints = problem.num_rows; + + csr_matrix_t csr_A(problem.num_rows, problem.num_cols, problem.A.nnz()); + problem.A.to_compressed_row(csr_A); + std::vector coefficients = csr_A.x; + std::vector variables = csr_A.j; + std::vector offsets = csr_A.row_start; + std::vector constraint_lower_bounds = problem.rhs; + std::vector constraint_upper_bounds = problem.rhs; std::vector variable_bounds(n_variables); + std::vector cpufj_variable_types(n_variables); std::vector is_binary_variable(n_variables, 0); i_t n_integer_vars = 0; + for (i_t j = 0; j < n_variables; ++j) { - variable_bounds[j] = f_t2{model.var_lb[j], model.var_ub[j]}; - if (model.var_types[j] != var_t::INTEGER) { continue; } - ++n_integer_vars; - const bool is_binary = - integer_equal(model.var_lb[j], f_t{0}, tolerances.integrality_tolerance) && - integer_equal(model.var_ub[j], f_t{1}, tolerances.integrality_tolerance); + variable_bounds[j] = f_t2{problem.lower[j], problem.upper[j]}; + const auto var_type = variable_types[j]; + cpufj_variable_types[j] = + var_type == variable_type_t::CONTINUOUS ? var_t::CONTINUOUS : var_t::INTEGER; + + const bool is_integer = cpufj_variable_types[j] == var_t::INTEGER; + const bool is_binary = is_integer && + integer_equal(problem.lower[j], f_t{0}, settings.integer_tol) && + integer_equal(problem.upper[j], f_t{1}, settings.integer_tol); + if (is_integer) { ++n_integer_vars; } if (is_binary) { is_binary_variable[j] = 1; } } + const i_t nnz = static_cast(variables.size()); + csc_matrix_t reverse_csc(n_constraints, n_variables, nnz); + csr_A.to_compressed_col(reverse_csc); + std::vector reverse_coefficients = std::move(reverse_csc.x); + std::vector reverse_constraints = std::move(reverse_csc.i); + std::vector reverse_offsets = std::move(reverse_csc.col_start); + std::vector projected_seed(n_variables, f_t{0}); for (i_t j = 0; j < n_variables; ++j) { f_t value = j < static_cast(seed_assignment.size()) ? seed_assignment[j] : f_t{0}; - value = std::clamp(value, model.var_lb[j], model.var_ub[j]); - if (model.var_types[j] != var_t::CONTINUOUS) { - value = std::clamp(std::round(value), model.var_lb[j], model.var_ub[j]); + value = std::clamp(value, problem.lower[j], problem.upper[j]); + if (variable_types[j] != variable_type_t::CONTINUOUS) { + value = std::clamp(std::round(value), problem.lower[j], problem.upper[j]); } projected_seed[j] = value; } - csr_matrix_t csr_A(n_constraints, n_variables, nnz); - csr_A.x = std::move(model.coefficients); - csr_A.j = std::move(model.variables); - csr_A.row_start = std::move(model.offsets); - csc_matrix_t reverse_csc(n_constraints, n_variables, nnz); - csr_A.to_compressed_col(reverse_csc); + fj_settings_t fj_settings; + fj_settings.mode = fj_mode_t::EXIT_NON_IMPROVING; + fj_settings.n_of_minimums_for_exit = std::numeric_limits::max(); + fj_settings.time_limit = std::numeric_limits::infinity(); + fj_settings.iteration_limit = std::numeric_limits::max(); + fj_settings.update_weights = true; + fj_settings.feasibility_run = false; + fj_settings.seed = seed >= 0 ? seed : cuopt::seed_generator::get_seed(); auto fj_cpu = std::make_unique>(preemption_flag); fj_cpu->view = typename fj_t::climber_data_t::view_t{}; fj_cpu->pb_ptr = nullptr; - fj_cpu->settings = settings; - - fj_cpu->h_reverse_coefficients = std::move(reverse_csc.x); - fj_cpu->h_reverse_constraints = std::move(reverse_csc.i); - fj_cpu->h_reverse_offsets = std::move(reverse_csc.col_start); - fj_cpu->h_coefficients = std::move(csr_A.x); - fj_cpu->h_offsets = std::move(csr_A.row_start); - fj_cpu->h_variables = std::move(csr_A.j); - fj_cpu->h_obj_coeffs = std::move(model.objective); + fj_cpu->settings = fj_settings; + + fj_cpu->h_reverse_coefficients = std::move(reverse_coefficients); + fj_cpu->h_reverse_constraints = std::move(reverse_constraints); + fj_cpu->h_reverse_offsets = std::move(reverse_offsets); + fj_cpu->h_coefficients = std::move(coefficients); + fj_cpu->h_offsets = std::move(offsets); + fj_cpu->h_variables = std::move(variables); + fj_cpu->h_obj_coeffs = problem.objective; fj_cpu->h_var_bounds = std::move(variable_bounds); - fj_cpu->h_cstr_lb = std::move(model.row_lb); - fj_cpu->h_cstr_ub = std::move(model.row_ub); - fj_cpu->h_var_types = std::move(model.var_types); + fj_cpu->h_cstr_lb = std::move(constraint_lower_bounds); + fj_cpu->h_cstr_ub = std::move(constraint_upper_bounds); + fj_cpu->h_var_types = std::move(cpufj_variable_types); fj_cpu->h_is_binary_variable = std::move(is_binary_variable); fj_cpu->h_cstr_left_weights.resize(n_constraints, 1.0); @@ -1510,59 +1531,6 @@ std::unique_ptr> init_fj_cpu_from_host_model( return fj_cpu; } -template -static std::unique_ptr> init_fj_cpu_from_host_lp( - const lp_problem_t& problem, - const std::vector& variable_types, - const std::vector& seed_assignment, - const simplex_solver_settings_t& settings, - std::atomic& preemption_flag, - int64_t seed) -{ - cuopt_assert(variable_types.size() >= static_cast(problem.num_cols), - "variable type size mismatch"); - - typename mip_solver_settings_t::tolerances_t tolerances{}; - tolerances.absolute_tolerance = settings.primal_tol; - tolerances.relative_tolerance = settings.zero_tol; - tolerances.integrality_tolerance = settings.integer_tol; - tolerances.absolute_mip_gap = settings.absolute_mip_gap_tol; - tolerances.relative_mip_gap = settings.relative_mip_gap_tol; - - fj_cpu_host_model_t model; - model.n_variables = problem.num_cols; - model.n_constraints = problem.num_rows; - - csr_matrix_t csr_A(problem.num_rows, problem.num_cols, problem.A.nnz()); - problem.A.to_compressed_row(csr_A); - model.coefficients = std::move(csr_A.x); - model.variables = std::move(csr_A.j); - model.offsets = std::move(csr_A.row_start); - // Standard form: every row is an equality. - model.row_lb = problem.rhs; - model.row_ub = problem.rhs; - model.var_lb = problem.lower; - model.var_ub = problem.upper; - model.objective = problem.objective; - model.var_types.resize(model.n_variables); - for (i_t j = 0; j < model.n_variables; ++j) { - model.var_types[j] = - variable_types[j] == variable_type_t::CONTINUOUS ? var_t::CONTINUOUS : var_t::INTEGER; - } - - fj_settings_t fj_settings; - fj_settings.mode = fj_mode_t::EXIT_NON_IMPROVING; - fj_settings.n_of_minimums_for_exit = std::numeric_limits::max(); - fj_settings.time_limit = std::numeric_limits::infinity(); - fj_settings.iteration_limit = std::numeric_limits::max(); - fj_settings.update_weights = true; - fj_settings.feasibility_run = false; - fj_settings.seed = seed >= 0 ? seed : cuopt::seed_generator::get_seed(); - - return init_fj_cpu_from_host_model( - std::move(model), seed_assignment, tolerances, preemption_flag, fj_settings); -} - template static void sanity_checks(fj_cpu_climber_t& fj_cpu) { @@ -1885,12 +1853,6 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); -template std::unique_ptr> init_fj_cpu_from_host_model( - fj_cpu_host_model_t model, - const std::vector& seed_assignment, - const typename mip_solver_settings_t::tolerances_t& tolerances, - std::atomic& preemption_flag, - fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, @@ -1911,12 +1873,6 @@ template std::unique_ptr> init_fj_cpu_standalone( solution_t& solution, std::atomic& preemption_flag, fj_settings_t settings); -template std::unique_ptr> init_fj_cpu_from_host_model( - fj_cpu_host_model_t model, - const std::vector& seed_assignment, - const typename mip_solver_settings_t::tolerances_t& tolerances, - std::atomic& preemption_flag, - fj_settings_t settings); template void finalize_fj_cpu_host_initialization( fj_cpu_climber_t& fj_cpu, int n_variables, diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 3fc94f2f59..718c89615d 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -204,31 +204,4 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); -// A model held entirely on the host, row major. Row bounds are carried as given, so ranged and one -// sided rows need no slack columns. -template -struct fj_cpu_host_model_t { - i_t n_variables{0}; - i_t n_constraints{0}; - std::vector coefficients; - std::vector variables; - std::vector offsets; - std::vector row_lb; - std::vector row_ub; - std::vector var_lb; - std::vector var_ub; - std::vector objective; - std::vector var_types; -}; - -// CPUFJ init from host arrays alone: no problem_t, no device allocation, no handle. The model is -// taken by value and moved from. Callers holding a device problem want init_fj_cpu_standalone. -template -std::unique_ptr> init_fj_cpu_from_host_model( - fj_cpu_host_model_t model, - const std::vector& seed_assignment, - const typename mip_solver_settings_t::tolerances_t& tolerances, - std::atomic& preemption_flag, - fj_settings_t settings); - } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index 67240e6095..58a29c5182 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -19,14 +19,9 @@ #define CUOPT_MIP_EARLY_GPUFJ_REQUIRED_THREAD_COUNT 3 #define CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT 2 #define CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT 2 - -// Thread slots the root structural pass must leave free for the tasks that share the team while it -// runs: the B&B task itself, dual simplex, the diversity manager, the concurrent barrier, clique -// extension, the LS scratch climber and the ls_cpu_fj taskloop. B&B's own worker pools are not -// created until the cut loop ends, which is what makes the remaining slots safe to take. -#define CUOPT_MIP_ROOT_STRUCTURAL_RESERVED_THREADS 8 -#define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 -#define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 +#define CUOPT_MIP_ROOT_STRUCTURAL_REQUIRED_THREAD_COUNT 3 +#define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 +#define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 // MIP-only gate: skip the concurrent barrier when fewer threads are available than this // (1 PDLP + 1 dual simplex + 1 barrier). Stand-alone LP always runs all three. diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 12a82c020b..b841c9b5f7 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -320,14 +320,16 @@ mip_solution_t run_mip_solver( solver.context.early_cpufj_ptr = early_cpufj.get(); CUOPT_LOG_DEBUG("Started early CPUFJ on papilo-presolved problem during cuOpt presolve"); - early_structural = std::make_unique>( + early_structural = mip::early_structural_t::create( *problem.original_problem_ptr, settings.get_tolerances(), incumbent_callback); - if (std::isfinite(initial_upper_bound)) { - early_structural->set_best_objective( - problem.get_solver_obj_from_user_obj(initial_upper_bound)); + if (early_structural) { + if (std::isfinite(initial_upper_bound)) { + early_structural->set_best_objective( + problem.get_solver_obj_from_user_obj(initial_upper_bound)); + } + early_structural->start(); + solver.context.early_structural_ptr = early_structural.get(); } - early_structural->start(); - solver.context.early_structural_ptr = early_structural.get(); } auto presolved_sol = solver.run_solver(); @@ -583,9 +585,9 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::make_unique>(op_problem, settings, early_fj_callback); early_gpufj->start(); CUOPT_LOG_DEBUG("Started early GPUFJ during presolve"); - early_structural = std::make_unique>( + early_structural = mip::early_structural_t::create( op_problem, settings.get_tolerances(), early_fj_callback); - early_structural->start(); + if (early_structural) { early_structural->start(); } } auto constexpr const dual_postsolve = false; @@ -683,8 +685,8 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p if (early_structural) { early_structural->stop(); if (early_structural->solution_found()) { - CUOPT_LOG_DEBUG("Early %s (original) found incumbent with objective %.6e during presolve", - early_structural->recognized_name(), + CUOPT_LOG_DEBUG("Early structural heuristic (original) found incumbent with objective %.6e " + "during presolve", early_structural->get_best_objective()); } early_structural.reset(); diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index bd0dbb1908..9ee2f29be9 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -31,9 +31,6 @@ #include #include -#include -#include -#include #include #include #include @@ -234,8 +231,8 @@ solution_t mip_solver_t::run_solver() if (context.early_structural_ptr) { context.early_structural_ptr->stop(); if (context.early_structural_ptr->solution_found()) { - CUOPT_LOG_DEBUG("Early %s found incumbent with user-space objective %g during presolve", - context.early_structural_ptr->recognized_name(), + CUOPT_LOG_DEBUG("Early structural heuristic found incumbent with user-space objective %g " + "during presolve", context.early_structural_ptr->get_best_user_objective()); } } @@ -493,33 +490,18 @@ solution_t mip_solver_t::run_solver() } } - // Runs alongside the root relaxation and the cut loop, on the thread slots B&B's worker pools - // only claim once the cut loop ends. Recognition is the gate: on a model with no structure to - // exploit this costs one host scan and nothing is launched. - const i_t root_structural_lanes = - context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC - ? 0 - : std::max(num_threads - CUOPT_MIP_ROOT_STRUCTURAL_RESERVED_THREADS, 0); - // The generators detect_symmetry already produced above, as plain column permutations: a - // structural heuristic recovers its own object from the model and only needs to know which - // columns are interchangeable, not how that was established. - std::vector> column_symmetry; - if (context.symmetry != nullptr) { - const auto& generators = context.symmetry->generators; - column_symmetry.reserve(generators.num_generators()); - for (size_t index = 0; index < generators.num_generators(); ++index) { - column_symmetry.push_back( - generators.get_generator(static_cast(index)).dense_permutation()); - } - } std::unique_ptr> root_structural; - if (root_structural_lanes > 0 && !context.settings.heuristics_only) { + if (num_threads >= CUOPT_MIP_ROOT_STRUCTURAL_REQUIRED_THREAD_COUNT && + context.settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && + !context.settings.heuristics_only) { root_structural = std::make_unique>( *context.problem_ptr, context.settings.get_tolerances(), context.preempt_heuristic_solver_, - column_symmetry.empty() ? nullptr : &column_symmetry, - root_structural_lanes); + [&dm](const std::vector& assignment, f_t objective) { + dm.population.add_external_solution( + assignment, objective, solution_origin_t::EXTERNAL); + }); if (!root_structural->recognized()) { root_structural.reset(); } } diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index 0d6c12969f..f68f279ead 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -26,15 +26,11 @@ namespace cuopt::mathematical_optimization::mip { namespace { -// Number of source-to-sink paths the frontier dynamic program enumerates. The state is a -// canonical tuple of this many frontier nodes, so the reachable state count grows roughly as the -// node count raised to one less than this. constexpr int arcflow_paths_supported = 2; -// Upper bound on the expanded covering demand, which is the number of dynamic program levels. constexpr int arcflow_max_tokens = 20000; constexpr int arcflow_max_col_entries = 3; -// Ceiling on the reconstruction history. Reaching it forces a beam and gives up exactness. constexpr size_t arcflow_history_bytes_max = size_t{32} << 20; +constexpr size_t arcflow_candidate_bytes_max = size_t{32} << 20; // Structural inference reads objective coefficients and matrix entries at f_t precision, so the // residual a genuine arc-flow model leaves behind is bounded by that precision and not by double. @@ -60,36 +56,30 @@ struct arc_t { double cost{0.0}; }; -// Everything the construction needs, derived without reading any row, column or name order. struct arc_flow_model_t { int n_nodes{0}; int n_labels{0}; arcflow_tol_t tol; - std::vector phi; // potential per node - std::vector path_start; // one entry per path, expanded by supply multiplicity - std::vector demand; // per label - std::vector displacement; // per label - std::vector slope; // per label, NaN when the cost slope is unidentifiable - std::vector arc_offset; // per label, CSR into arcs - std::vector arcs; // grouped by label, ascending by tail within a label - - // A path may end at a node either through an explicit unlabelled arc or through slack in the - // node's conservation row. A negative terminator_col marks the row-slack encoding, which sets - // no variable. + std::vector phi; + std::vector path_start; + std::vector demand; + std::vector displacement; + std::vector slope; + std::vector arc_offset; + std::vector arcs; + + // A negative terminator_col denotes conservation-row slack. std::vector terminator_col; std::vector terminator_cost; std::vector terminator_capacity; }; -// Live state of the search. Kept for the current level only. struct frontier_t { std::array node{}; double cost{0.0}; }; -// Retained for every level so the winning path can be walked back. Deliberately narrow: this is -// what the memory budget is spent on. struct parent_t { int prev{-1}; int arc{-1}; @@ -101,11 +91,8 @@ struct candidate_t { }; struct arc_flow_result_t { - std::vector columns; // selected columns, repeated for multiplicity - double cost{0.0}; - bool exact{true}; // false once the history budget forced a beam - size_t peak_raw{0}; // widest level before merging equal states - size_t peak_kept{0}; // widest level actually retained + std::vector columns; + bool exact{true}; }; bool close_to(double a, double b, double scale, const arcflow_tol_t& tol) @@ -120,10 +107,19 @@ bool is_integral(double v, const arcflow_tol_t& tol) bool is_known(double v) { return !std::isnan(v); } -// --------------------------------------------------------------------------------------------- -// Host mirror of the problem. The reverse (column major) matrix is the natural view here: the -// detector always asks which rows a column touches, never the other way round. -// --------------------------------------------------------------------------------------------- +struct arcflow_profile_t { + arcflow_profile_t(int64_t n_variables = 0, int64_t n_constraints = 0) + : col_entries(n_variables, 0), + row_min_mag(n_constraints, std::numeric_limits::infinity()), + row_max_mag(n_constraints, 0.0) + { + } + + std::vector col_entries; + std::vector row_min_mag; + std::vector row_max_mag; +}; + template struct host_problem_t { i_t n_variables{0}; @@ -140,9 +136,6 @@ struct host_problem_t { std::vector var_types; }; -// Recognition is three gates in ascending cost, so a model that cannot match is turned away before -// its matrix is read. Together they are the whole of recognition: the detector proper runs later, -// in solve(). Both problem sources drive the same gates, which is what keeps them agreeing. bool arcflow_accepts_shape(int64_t n_variables, int64_t n_constraints, int64_t nnz) { if (n_variables <= 0 || n_constraints <= 0) { return false; } @@ -170,21 +163,6 @@ bool arcflow_accepts_bounds(const std::vector& row_lb, const std::vector 0 && n_cover_candidates > 0; } -// The only matrix facts recognition needs. Derived from whichever layout the caller already holds, -// which is what keeps the transpose off the declining path. -struct arcflow_profile_t { - arcflow_profile_t(int64_t n_variables, int64_t n_constraints) - : col_entries(n_variables, 0), - row_min_mag(n_constraints, std::numeric_limits::infinity()), - row_max_mag(n_constraints, 0.0) - { - } - - std::vector col_entries; - std::vector row_min_mag; - std::vector row_max_mag; -}; - template arcflow_profile_t profile_from_csr(i_t n_variables, i_t n_constraints, @@ -227,8 +205,6 @@ arcflow_profile_t profile_from_csc(i_t n_variables, return p; } -// The column entry cap, one shared coefficient magnitude per row, and an integral covering demand -// within the level budget. template bool arcflow_accepts_profile(const arcflow_profile_t& p, const std::vector& row_lb, @@ -280,43 +256,25 @@ void transpose_into_csc(const std::vector& csr_values, } } -// --------------------------------------------------------------------------------------------- -// Row classification -// --------------------------------------------------------------------------------------------- - struct row_info_t { row_role_t role{row_role_t::cover}; - double scale{1.0}; // common magnitude of the row coefficients - double lo{0.0}; // bounds divided by scale, later reoriented + double scale{1.0}; + double lo{0.0}; double hi{0.0}; }; -// A row is usable only if all its coefficients share one magnitude, -// The role is seeded from the bounds and verified afterwards against the column patterns: a flow -// row is two sided, a covering row is bounded from below only. Presolve can turn a flow equality -// into a range row, so the seed must not test for equality. A covering row that acquired a finite -// upper bound is consequently misread as a flow row and rejected downstream; that is a known limit. template -bool classify_rows(const host_problem_t& h, std::vector& rows) +bool classify_rows(const host_problem_t& h, + const arcflow_profile_t& profile, + std::vector& rows) { + cuopt_assert((i_t)profile.row_min_mag.size() == h.n_constraints, "Size mismatch"); + cuopt_assert((i_t)profile.row_max_mag.size() == h.n_constraints, "Size mismatch"); rows.assign(h.n_constraints, row_info_t{}); - std::vector min_mag(h.n_constraints, std::numeric_limits::infinity()); - std::vector max_mag(h.n_constraints, 0.0); - - for (size_t k = 0; k < h.csc_rows.size(); ++k) { - const int r = h.csc_rows[k]; - const double mag = std::abs((double)h.csc_values[k]); - if (mag <= h.tol.abs) { return false; } - min_mag[r] = std::min(min_mag[r], mag); - max_mag[r] = std::max(max_mag[r], mag); - } for (int r = 0; r < h.n_constraints; ++r) { - if (max_mag[r] == 0.0) { return false; } - if (!close_to(min_mag[r], max_mag[r], max_mag[r], h.tol)) { return false; } - row_info_t info; - info.scale = max_mag[r]; + info.scale = profile.row_max_mag[r]; const double lo = h.row_lb[r] / info.scale; const double hi = h.row_ub[r] / info.scale; const bool lo_fin = std::isfinite(lo); @@ -364,12 +322,6 @@ double supply_orientation(const std::vector& rows, return 0.0; } -// --------------------------------------------------------------------------------------------- -// Structure extraction -// --------------------------------------------------------------------------------------------- - -// Walks the columns once and fills the arc set, the covering demands, the path starts and the -// per-node termination capacity. Rejects anything that does not match the labelled arc pattern. // A column carrying a single flow incidence is accepted only when that incidence is the arc's // tail: sources are read from the right-hand side, so the mirror encoding of an explicit // injection arc is out of scope here. @@ -383,7 +335,7 @@ bool build_structure(const host_problem_t& h, double supply_total = 0.0; const double sign = supply_orientation(rows, h.tol, supply_total); if (sign == 0.0 || !is_integral(supply_total, h.tol)) { return false; } - if ((int)std::round(supply_total) != arcflow_paths_supported) { return false; } + if (std::round(supply_total) != arcflow_paths_supported) { return false; } // Reorient so a normalized coefficient of +1 always means "the arc leaves this node". if (sign < 0.0) { @@ -420,9 +372,7 @@ bool build_structure(const host_problem_t& h, } if (total_demand <= 0 || total_demand > arcflow_max_tokens) { return false; } - // Node roles from the oriented conservation bounds. A source emits a fixed number of paths; a - // node whose lower bound allows negative net outflow absorbs them, which is the presolved - // encoding of a loss arc. + // Negative net outflow encodes path termination after singleton-column substitution. for (int r = 0; r < h.n_constraints; ++r) { const int v = node_of_row[r]; if (v < 0) { continue; } @@ -435,7 +385,8 @@ bool build_structure(const host_problem_t& h, } else if (std::abs(info.hi) <= h.tol.abs) { if (info.lo < -h.tol.abs) { if (!is_integral(info.lo, h.tol)) { return false; } - model.terminator_capacity[v] = std::round(-info.lo); + model.terminator_capacity[v] = + std::min((double)arcflow_paths_supported, std::round(-info.lo)); } } else { return false; @@ -492,7 +443,8 @@ bool build_structure(const host_problem_t& h, if (model.terminator_col[tail] >= 0 || model.terminator_capacity[tail] > 0) { return false; } model.terminator_col[tail] = j; model.terminator_cost[tail] = h.obj[j]; - model.terminator_capacity[tail] = std::floor(ub + h.tol.abs); + model.terminator_capacity[tail] = + std::min((double)arcflow_paths_supported, std::floor(ub + h.tol.abs)); } else { return false; } @@ -518,10 +470,6 @@ bool build_structure(const host_problem_t& h, return true; } -// --------------------------------------------------------------------------------------------- -// Potential and cost model -// --------------------------------------------------------------------------------------------- - // The potential is recovered from the objective rather than from any index: within a label the // cost is affine in the potential of the arc's tail, so one label with enough distinct costs fixes // the potential on every node it touches, and the remaining labels are fitted and extended from @@ -559,10 +507,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti model.phi[model.arcs[k].from] = model.arcs[k].cost; } - // Every round that changes anything resolves at least one potential, slope or displacement, so - // this many rounds is an upper bound on the productive ones and the loop always reaches a true - // fixpoint. The bound exists to keep a malformed model from spinning, not to cap propagation - // depth: a chain of labels longer than any fixed constant still resolves. + // Each productive round resolves at least one potential, slope, or displacement. const long max_rounds = 2L * n_labels + model.n_nodes + 2L; long rounds = 0; for (; rounds < max_rounds; ++rounds) { @@ -601,7 +546,6 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti } } - // Close the potential through the arcs themselves once the displacement is pinned down. if (!is_known(model.displacement[l])) { for (int k = begin; k < end; ++k) { const double from = model.phi[model.arcs[k].from]; @@ -692,19 +636,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti return true; } -// --------------------------------------------------------------------------------------------- -// Token order -// --------------------------------------------------------------------------------------------- - -// Weighted shortest processing time: labels by decreasing cost slope over displacement, compared -// by cross multiplication so the ordering never turns on the rounding of a division. Both -// quantities come from the arc set, so the order is invariant under any permutation of the model, -// and the affine freedom left in the potential scales every ratio by the same positive factor. -// -// A slope needs two arcs at distinct potentials to fit, so a label that has only one, or whose arcs -// all share a tail, has no identified ratio. Its position is still determined, by reachability -// rather than by cost, and `ordering_exact` reports whether any label was placed that way: the -// ordered family searched is then not the Smith-ordered one and the result is heuristic. +// Weighted shortest processing time orders labels by decreasing slope over displacement. std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact) { std::vector lowest_phi(model.n_labels, 0.0); @@ -732,11 +664,7 @@ std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact return (long double)model.slope[a] * (long double)model.displacement[b]; }; - // Comparing ratios within a tolerance is not an ordering: approximate equality is not transitive, - // so three ratios pairwise within one step but further apart end to end make the comparator - // cyclic, and std::sort on a cyclic comparator is undefined behaviour rather than a bad order. - // The tolerance is applied once, to cut the ratios into classes, and everything the sort sees - // afterwards is compared exactly. + // Approximate equality is not transitive. Tolerance forms ratio classes before the final sort. std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { const long double lhs = cross(a, b); const long double rhs = cross(b, a); @@ -772,10 +700,7 @@ std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact tokens.insert(tokens.end(), (size_t)model.demand[l], l); } - // A label whose slope could not be fitted has too few arcs to place by weighted shortest - // processing time, but its arcs still say where it can go: a frontier can only reach potential - // x once the tokens consumed so far displace at least x. Insert it at the first such position - // rather than assuming an unfittable label belongs at the front. + // Labels without fitted slopes are placed at their first reachable potential. ordering_exact = unidentified.empty(); std::sort(unidentified.begin(), unidentified.end(), [&](int a, int b) { if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } @@ -793,10 +718,6 @@ std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact return tokens; } -// --------------------------------------------------------------------------------------------- -// Frontier dynamic program -// --------------------------------------------------------------------------------------------- - bool state_less(const frontier_t& a, const frontier_t& b) { return std::lexicographical_compare(a.node.begin(), a.node.end(), b.node.begin(), b.node.end()); @@ -813,9 +734,7 @@ std::optional run_dp(const arc_flow_model_t& model, arc_flow_result_t result; - // Only the reconstruction history is retained across levels, so the budget is charged against - // what is actually kept as it accumulates. Deriving a fixed width from the budget up front - // would have to assume the peak width at every level, which beams searches that fit easily. + // The reconstruction budget is charged against retained states at each level. size_t retained_bytes = 0; std::vector> history; @@ -837,15 +756,36 @@ std::optional run_dp(const arc_flow_model_t& model, const auto arc_begin = model.arcs.begin() + model.arc_offset[label]; const auto arc_end = model.arcs.begin() + model.arc_offset[label + 1]; + const size_t candidate_limit = arcflow_candidate_bytes_max / sizeof(candidate_t); + size_t candidate_count = 0; + for (const auto& entry : current) { + for (int k = 0; k < arcflow_paths_supported; ++k) { + const int node = entry.node[k]; + const auto begin = + std::lower_bound(arc_begin, arc_end, node, [](const arc_t& a, int v) { + return a.from < v; + }); + const auto end = std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { + return v < a.from; + }); + const size_t added = end - begin; + if (added > candidate_limit - candidate_count) { return std::nullopt; } + candidate_count += added; + } + } + candidates.clear(); - candidates.reserve(current.size() * arcflow_paths_supported); + candidates.reserve(candidate_count); for (int i = 0; i < (int)current.size(); ++i) { const frontier_t& entry = current[i]; for (int k = 0; k < arcflow_paths_supported; ++k) { const int node = entry.node[k]; - auto it = std::lower_bound( + const auto begin = std::lower_bound( arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); - for (; it != arc_end && it->from == node; ++it) { + const auto end = std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { + return v < a.from; + }); + for (auto it = begin; it != end; ++it) { candidate_t candidate; candidate.front = entry; candidate.front.node[k] = it->to; @@ -857,10 +797,8 @@ std::optional run_dp(const arc_flow_model_t& model, } } if (candidates.empty()) { return std::nullopt; } - result.peak_raw = std::max(result.peak_raw, candidates.size()); - // A total order over the whole record keeps the surviving representative of a state - // independent of the enumeration order, so the result is reproducible run to run. + // A total order makes representative selection independent of enumeration order. std::sort(candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { if (!state_equal(a.front, b.front)) { return state_less(a.front, b.front); } if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } @@ -900,12 +838,10 @@ std::optional run_dp(const arc_flow_model_t& model, parents.push_back(candidate.parent); } retained_bytes += parents.size() * sizeof(parent_t); - result.peak_kept = std::max(result.peak_kept, next.size()); history.push_back(std::move(parents)); current.swap(next); } - // Close every frontier, respecting how many paths a node may absorb. int best_index = -1; double best_total = std::numeric_limits::infinity(); for (int i = 0; i < (int)current.size(); ++i) { @@ -931,7 +867,6 @@ std::optional run_dp(const arc_flow_model_t& model, } if (best_index < 0) { return std::nullopt; } - result.cost = best_total; for (int k = 0; k < arcflow_paths_supported; ++k) { const int col = model.terminator_col[current[best_index].node[k]]; if (col >= 0) { result.columns.push_back(col); } @@ -949,13 +884,10 @@ std::optional run_dp(const arc_flow_model_t& model, } // namespace -// --------------------------------------------------------------------------------------------- -// Public surface -// --------------------------------------------------------------------------------------------- - template struct arc_flow_t::host_state_t { host_problem_t h; + arcflow_profile_t profile; }; template @@ -964,10 +896,6 @@ arc_flow_t::arc_flow_t() = default; template arc_flow_t::~arc_flow_t() = default; -// A model that passes keeps its host mirror, which is what solve() then reads. Each gate fetches -// only what it reads, and the transpose is built once the model is accepted: a declining model pays -// for the row bounds and one pass over the matrix, nothing more. Each fetch is issued as a batch -// and waited on once. template bool arc_flow_t::recognize(const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t&) @@ -1004,7 +932,7 @@ bool arc_flow_t::recognize(const optimization_problem_t& op_ stream.synchronize(); const arcflow_tol_t tol = structural_tolerance(); - const auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); + auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } const auto& d_obj = op_problem.get_objective_coefficients(); @@ -1012,9 +940,9 @@ bool arc_flow_t::recognize(const optimization_problem_t& op_ const auto& d_var_ub = op_problem.get_variable_upper_bounds(); const auto& d_var_types = op_problem.get_variable_types(); cuopt_assert((i_t)d_obj.size() == n_variables, "Size mismatch"); - cuopt_assert((i_t)d_var_lb.size() == n_variables, "Size mismatch"); - cuopt_assert((i_t)d_var_ub.size() == n_variables, "Size mismatch"); cuopt_assert((i_t)d_var_types.size() == n_variables, "Size mismatch"); + if (!d_var_lb.is_empty() && (i_t)d_var_lb.size() != n_variables) { return false; } + if ((i_t)d_var_ub.size() != n_variables) { return false; } auto state = std::make_unique(); state->h.n_variables = n_variables; @@ -1023,22 +951,23 @@ bool arc_flow_t::recognize(const optimization_problem_t& op_ state->h.row_lb = std::move(row_lb); state->h.row_ub = std::move(row_ub); state->h.obj.resize(d_obj.size()); - state->h.var_lb.resize(d_var_lb.size()); + state->h.var_lb.assign(n_variables, f_t{0}); state->h.var_ub.resize(d_var_ub.size()); state->h.var_types.resize(d_var_types.size()); raft::copy(state->h.obj.data(), d_obj.data(), state->h.obj.size(), stream); - raft::copy(state->h.var_lb.data(), d_var_lb.data(), state->h.var_lb.size(), stream); + if (!d_var_lb.is_empty()) { + raft::copy(state->h.var_lb.data(), d_var_lb.data(), state->h.var_lb.size(), stream); + } raft::copy(state->h.var_ub.data(), d_var_ub.data(), state->h.var_ub.size(), stream); raft::copy(state->h.var_types.data(), d_var_types.data(), state->h.var_types.size(), stream); stream.synchronize(); transpose_into_csc(values, indices, offsets, state->h); + state->profile = std::move(profile); state_ = std::move(state); return true; } -// The root position hands over the fully reduced problem, which already carries the column major -// view this detector wants, so the gates read it directly and no transpose is needed at all. template bool arc_flow_t::recognize(const problem_t& problem, const typename mip_solver_settings_t::tolerances_t&) @@ -1069,7 +998,7 @@ bool arc_flow_t::recognize(const problem_t& problem, cuopt_assert(csc_values.size() == csc_rows.size(), "Size mismatch"); const arcflow_tol_t tol = structural_tolerance(); - const auto profile = + auto profile = profile_from_csc(n_variables, n_constraints, csc_values, csc_rows, csc_offsets); if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } @@ -1094,6 +1023,7 @@ bool arc_flow_t::recognize(const problem_t& problem, stream.synchronize(); std::tie(state->h.var_lb, state->h.var_ub) = cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); + state->profile = std::move(profile); state_ = std::move(state); return true; @@ -1103,14 +1033,13 @@ template structural_outcome_t arc_flow_t::solve( const typename mip_solver_settings_t::tolerances_t&, std::atomic& preemption, - double, std::vector& assignment) { cuopt_assert(state_ != nullptr, "solve called without a successful recognize"); const auto& h = state_->h; std::vector rows; - if (!classify_rows(h, rows)) { + if (!classify_rows(h, state_->profile, rows)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: rows are not unit incidence after normalization"); return structural_outcome_t::declined; } @@ -1142,14 +1071,10 @@ structural_outcome_t arc_flow_t::solve( CUOPT_LOG_DEBUG("[ArcFlow] no complete path set found in the ordered family"); return structural_outcome_t::declined; } - // Exact means exact for what was searched: the dynamic program is optimal over the token order it - // was given, so an order that was not fully identified makes the result heuristic however - // complete the search of it was. + // Unidentified token order makes an otherwise complete search heuristic. search_was_exact_ = result->exact && ordering_exact; - CUOPT_LOG_DEBUG("[ArcFlow] search %s, peak width %zu raw and %zu retained", - result->exact ? "exact" : "beamed by the history budget", - result->peak_raw, - result->peak_kept); + CUOPT_LOG_DEBUG( + "[ArcFlow] search %s", result->exact ? "exact" : "beamed by the history budget"); assignment.assign((size_t)h.n_variables, f_t{0}); for (int col : result->columns) { diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cuh b/cpp/src/mip_heuristics/structural/arc_flow.cuh index 0d938b5cf2..239f886508 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cuh +++ b/cpp/src/mip_heuristics/structural/arc_flow.cuh @@ -11,27 +11,8 @@ namespace cuopt::mathematical_optimization::mip { -// Constructive primal heuristic for enhanced arc-flow models: m source-to-sink paths through a -// DAG whose internal arcs each carry one covering-row label, in the Valerio de Carvalho arc-flow -// lineage. It consumes the covering demands as an ordered token sequence and solves the resulting -// frontier dynamic program, which is optimal over that sequence unless the reconstruction history -// exceeds its budget and forces a beam. -// -// The recognized family is narrower than arc-flow in general. The node potential and the token -// order are both recovered from the objective, so an arc's cost must be affine in the potential of -// its tail, and a negative fitted slope is rejected. Fitting a slope needs two arcs at distinct -// potentials: a label that has fewer is placed by reachability instead, which is a position its arc -// set determines but not the one weighted shortest processing time would give, so the sequence -// searched is no longer the Smith-ordered one. Path termination is recognized in either of its -// encodings, an explicit unlabelled arc or slack in the node's conservation row, which is what lets -// the same detector run before and after a presolve pass that substitutes bounded singleton columns -// out of their equality. Other presolve reductions, notably row aggregation and coefficient -// strengthening, destroy the pattern and are not handled. -// -// Detection reads only permutation-invariant data: row bounds, coefficient patterns, the -// right-hand side, and statistics derived from the arc set. Row order, column order and names are -// never consulted, so detection and the published objective are invariant under a permutation of -// the model. The selected support is not, and cannot be where the model has automorphisms. +// Constructs paths through labelled arc-flow DAGs. Arc costs must be affine in tail potential; +// path termination may use explicit loss arcs or conservation-row slack. template class arc_flow_t : public structural_heuristic_t { public: @@ -48,7 +29,6 @@ class arc_flow_t : public structural_heuristic_t { structural_outcome_t solve( const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, - double work_budget, std::vector& assignment) override; // True only when the constructed point is the optimum of the Smith-ordered family: false if the @@ -57,8 +37,6 @@ class arc_flow_t : public structural_heuristic_t { bool search_was_exact() const { return search_was_exact_; } private: - // Host mirror recovered by recognize(), consumed by solve(). Defined in the source, since its - // shape is the detector's business alone. struct host_state_t; std::unique_ptr state_; diff --git a/cpp/src/mip_heuristics/structural/early_structural.cu b/cpp/src/mip_heuristics/structural/early_structural.cu index 34c6bced18..95b0810198 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cu +++ b/cpp/src/mip_heuristics/structural/early_structural.cu @@ -9,28 +9,22 @@ #include #include +#include #include #include #include -#include #include #include namespace cuopt::mathematical_optimization::mip { -namespace { - -// Recomputed from the solver-space problem rather than from whatever structure a heuristic thinks -// it found, so a detection mistake cannot publish an infeasible point. Feasibility is decided by -// the solver's own tolerances: the question is whether the solver would accept this point. template -bool validate(const problem_t& problem, - const typename mip_solver_settings_t::tolerances_t& tolerances, - const std::vector& assignment, - f_t& objective) +static bool validate(const problem_t& problem, + const std::vector& assignment, + f_t& objective) { auto stream = problem.handle_ptr->get_stream(); @@ -46,28 +40,34 @@ bool validate(const problem_t& problem, if ((i_t)assignment.size() != problem.n_variables) { return false; } - const double integrality = tolerances.integrality_tolerance; - const double abs_tol = tolerances.absolute_tolerance; - const double rel_tol = tolerances.relative_tolerance; + const double integrality = problem.tolerances.integrality_tolerance; double obj_value = 0.0; for (i_t j = 0; j < problem.n_variables; ++j) { const double x = assignment[j]; + if (!std::isfinite(x)) { return false; } if (var_types[j] == var_t::INTEGER && std::abs(x - std::round(x)) > integrality) { return false; } - if (x < (double)var_lb[j] - abs_tol || x > (double)var_ub[j] + abs_tol) { return false; } + if (x < (double)var_lb[j] - integrality || x > (double)var_ub[j] + integrality) { + return false; + } obj_value += obj[j] * x; } + if (!std::isfinite(obj_value)) { return false; } for (i_t r = 0; r < problem.n_constraints; ++r) { double activity = 0.0; for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { activity += (double)csr_values[k] * (double)assignment[csr_cols[k]]; } - const double slack = abs_tol + rel_tol * std::max(1.0, std::abs(activity)); + if (!std::isfinite(activity)) { return false; } const double lo = row_lb[r]; const double hi = row_ub[r]; + const double slack = get_cstr_tolerance(lo, + hi, + problem.tolerances.absolute_tolerance, + problem.tolerances.relative_tolerance); if (std::isfinite(lo) && activity < lo - slack) { return false; } if (std::isfinite(hi) && activity > hi + slack) { return false; } } @@ -76,56 +76,51 @@ bool validate(const problem_t& problem, return true; } -} // namespace - template -early_structural_t::early_structural_t( +std::unique_ptr> early_structural_t::create( const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t& tolerances, early_incumbent_callback_t incumbent_callback) - : early_heuristic_t>(std::move(incumbent_callback)), - op_problem_(op_problem), - tolerances_(tolerances) { - auto arc_flow = std::make_unique>(); - if (arc_flow->recognize(op_problem, tolerances)) { active_ = std::move(arc_flow); } - - // The framework's problem copy is the expensive part of construction, so it is built only once a - // structure has been recognized. - if (active_) { - active_->set_lane_budget(omp_get_num_threads() - 1); - CUOPT_LOG_DEBUG("[Early Structural] %s recognized the model", active_->name()); - this->initialize_problem(op_problem, tolerances); - } + if (omp_get_num_threads() < CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT) { return nullptr; } + auto active = std::make_unique>(); + if (!active->recognize(op_problem, tolerances)) { return nullptr; } + return std::unique_ptr( + new early_structural_t(op_problem, tolerances, std::move(incumbent_callback), std::move(active))); } template -early_structural_t::~early_structural_t() +early_structural_t::early_structural_t( + const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback, + std::unique_ptr> active) + : early_heuristic_t>( + op_problem, tolerances, std::move(incumbent_callback)), + op_problem_(op_problem), + tolerances_(tolerances), + active_(std::move(active)) { - stop(); + cuopt_assert(active_ != nullptr, "missing structural heuristic"); + CUOPT_LOG_DEBUG("[Early Structural] %s recognized the model", active_->name()); } template -const char* early_structural_t::recognized_name() const +early_structural_t::~early_structural_t() { - return active_ ? active_->name() : nullptr; + stop(); } template void early_structural_t::start() { - if (!active_ || task_launched_ || - omp_get_num_threads() < CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT) { - return; - } + if (task_launched_) { return; } preemption_flag_.store(false); this->start_time_ = std::chrono::steady_clock::now(); task_launched_ = true; - // A data member is not a valid depend list item, so the dependence is named through a - // dereferenced pointer to it. stop() names the same storage, which is what pairs the taskwait - // with this task. + // OpenMP depend clauses require a variable or array element. auto* task_token = &preemption_flag_; CUOPT_LOG_DEBUG("Launching early structural task for %s", active_->name()); #pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) depend(out : *task_token) @@ -145,11 +140,10 @@ void early_structural_t::stop() CUOPT_LOG_DEBUG("[Early Structural] Stopped, solution_found=%d", (int)this->solution_found_); } -// An assignment built in op_problem space can only be published through post_process_assignment if -// preprocessing left the columns where they were: same count, no shifted lower bound, no split. template bool early_structural_t::preprocessing_is_identity() const { + // Recognition produces assignments in the source problem's column space. const auto& presolve_data = this->problem_ptr_->presolve_data; if (this->problem_ptr_->n_variables != op_problem_.get_n_variables()) { return false; } if ((i_t)presolve_data.variable_offsets.size() != this->problem_ptr_->n_variables) { @@ -170,16 +164,13 @@ void early_structural_t::run() cuopt_assert(active_ != nullptr, "task launched without a recognized structure"); std::vector assignment; - const structural_outcome_t outcome = - active_->solve(tolerances_, preemption_flag_, 0.0, assignment); + const structural_outcome_t outcome = active_->solve(tolerances_, preemption_flag_, assignment); if (outcome != structural_outcome_t::constructed) { CUOPT_LOG_DEBUG("[Early Structural] %s constructed nothing", active_->name()); return; } if (preemption_flag_.load()) { return; } - // Checked before validation rather than after: without column identity the assignment cannot be - // read in solver space at all, which is the space both the validator and the publication use. if (!preprocessing_is_identity()) { CUOPT_LOG_DEBUG( "[Early Structural] %s constructed a point but preprocessing moved the columns, discarding", @@ -188,7 +179,7 @@ void early_structural_t::run() } f_t objective{0}; - if (!validate(*this->problem_ptr_, tolerances_, assignment, objective)) { + if (!validate(*this->problem_ptr_, assignment, objective)) { CUOPT_LOG_DEBUG("[Early Structural] %s constructed a point that failed validation, discarding", active_->name()); return; @@ -201,44 +192,28 @@ root_structural_t::root_structural_t( problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, - const std::vector>* column_symmetry, - int lane_budget) - : problem_(problem), tolerances_(tolerances), preemption_(preemption) + structural_incumbent_callback_t incumbent_callback) + : problem_(problem), + tolerances_(tolerances), + preemption_(preemption), + incumbent_callback_(std::move(incumbent_callback)) { auto arc_flow = std::make_unique>(); if (arc_flow->recognize(problem, tolerances)) { active_ = std::move(arc_flow); } - if (active_) { - active_->set_lane_budget(lane_budget); - active_->set_column_symmetry(column_symmetry); - CUOPT_LOG_DEBUG("[Root Structural] %s recognized the model, %d lanes, %zu symmetry generators", - active_->name(), - lane_budget, - column_symmetry == nullptr ? size_t{0} : column_symmetry->size()); - } + if (active_) { CUOPT_LOG_DEBUG("[Root Structural] %s recognized the model", active_->name()); } } template root_structural_t::~root_structural_t() = default; -template -const char* root_structural_t::recognized_name() const -{ - return active_ ? active_->name() : nullptr; -} - template void root_structural_t::run() { if (!active_) { return; } - if (!problem_.branch_and_bound_callback) { - CUOPT_LOG_DEBUG("[Root Structural] no branch and bound to publish to, skipping"); - return; - } + cuopt_assert(incumbent_callback_ != nullptr, "missing incumbent callback"); - // The recognizer read this problem, so the point comes back in the space B&B branches in and - // needs no mapping. It is still validated: a detection mistake must not reach the tree. std::vector assignment; - const structural_outcome_t outcome = active_->solve(tolerances_, preemption_, 0.0, assignment); + const structural_outcome_t outcome = active_->solve(tolerances_, preemption_, assignment); if (outcome != structural_outcome_t::constructed) { CUOPT_LOG_DEBUG("[Root Structural] %s constructed nothing", active_->name()); return; @@ -246,18 +221,17 @@ void root_structural_t::run() if (preemption_.load()) { return; } f_t objective{0}; - if (!validate(problem_, tolerances_, assignment, objective)) { + if (!validate(problem_, assignment, objective)) { CUOPT_LOG_DEBUG("[Root Structural] %s constructed a point that failed validation, discarding", active_->name()); return; } + if (preemption_.load()) { return; } - const bool accepted = - problem_.branch_and_bound_callback(assignment, heuristics_origin_t::HEURISTICS); - CUOPT_LOG_DEBUG("[Root Structural] %s published objective %+.6e, accepted=%d", + incumbent_callback_(assignment, objective); + CUOPT_LOG_DEBUG("[Root Structural] %s queued objective %+.6e", active_->name(), - (double)problem_.get_user_obj_from_solver_obj(objective), - (int)accepted); + (double)problem_.get_user_obj_from_solver_obj(objective)); } #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/structural/early_structural.cuh b/cpp/src/mip_heuristics/structural/early_structural.cuh index bdebe0f642..f9cfe0c90d 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cuh +++ b/cpp/src/mip_heuristics/structural/early_structural.cuh @@ -11,18 +11,13 @@ #include #include +#include #include namespace cuopt::mathematical_optimization::mip { -enum class structural_outcome_t : uint8_t { declined, constructed, budget_exhausted }; +enum class structural_outcome_t : uint8_t { declined, constructed }; -// A primal heuristic for one recognizable model structure. Recognition and construction both live -// here: the pass owns everything from reading the model to producing a point, and the dispatcher -// owns the framework hook, the validation and the publication. -// -// Subclasses must be default constructible and cheap to construct, because the dispatcher builds -// one before it knows whether the model matches. All real work belongs in recognize() and solve(). template class structural_heuristic_t { public: @@ -30,80 +25,45 @@ class structural_heuristic_t { virtual const char* name() const = 0; - // Necessary conditions on the model, cheap enough to run on the solve's thread before any GPU - // work is committed. Non-const so a subclass can keep the host view it built here for solve(). - // The tolerances decide every comparison the detector makes, so they belong here rather than only - // in solve(): a view built against one set and read against another proves nothing. virtual bool recognize( const optimization_problem_t& op_problem, const typename mip_solver_settings_t::tolerances_t& tolerances) = 0; - // The root position hands over the fully reduced problem instead. Declining by default lets a - // heuristic take one position without implementing the other. virtual bool recognize(const problem_t&, const typename mip_solver_settings_t::tolerances_t&) { return false; } - // Full detection and construction over the view recognize() kept, which is why the model is not a - // parameter: it came from whichever source recognized it, and the assignment comes back indexed - // in that source's columns. A zero work_budget means unlimited. The preemption flag is not - // const because a sub-solver may need to bind it by reference; a subclass must only ever read it. virtual structural_outcome_t solve( const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, - double work_budget, std::vector& assignment) = 0; - - // How many threads the heuristic may use inside solve(). Only the position that launches it - // knows what else is sharing the team, so it is told rather than asking. - void set_lane_budget(int lanes) { lane_budget_ = lanes; } - - // Dense column permutations of the model, one per generator of its symmetry group, or null when - // none are known. Kept as plain permutations so a heuristic never depends on whatever computed - // them. The pointee must outlive solve(). - void set_column_symmetry(const std::vector>* generators) - { - column_symmetry_ = generators; - } - - protected: - int lane_budget() const { return lane_budget_; } - const std::vector>* column_symmetry() const { return column_symmetry_; } - - private: - int lane_budget_{1}; - const std::vector>* column_symmetry_{nullptr}; }; -// Runs whichever structural heuristic recognizes the model, on one task during presolve. Nothing -// is asked of the call site beyond construction: when no structure is recognized the object stays -// inert, having skipped the framework's problem copy entirely, and start() does nothing. template class early_structural_t : public early_heuristic_t> { public: - // op_problem must outlive this object: the publication gate reads its column count. - early_structural_t(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t& tolerances, - early_incumbent_callback_t incumbent_callback); + static std::unique_ptr create( + const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback); ~early_structural_t(); static constexpr const char* name() { return "Structural"; } - // Name of the heuristic that recognized the model, or nullptr when none did. - const char* recognized_name() const; - void start(); void stop(); private: - // Body of the task: solve, validate, publish. + early_structural_t(const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + early_incumbent_callback_t incumbent_callback, + std::unique_ptr> active); + void run(); - // True when preprocessing left the column space of problem_ptr_ identical to op_problem's, which - // is what lets an assignment built in op_problem space be published through post_process. bool preprocessing_is_identity() const; const optimization_problem_t& op_problem_; @@ -113,36 +73,29 @@ class early_structural_t : public early_heuristic_t +using structural_incumbent_callback_t = + std::function& assignment, f_t objective)>; + template class root_structural_t { public: - // problem, preemption and column_symmetry must outlive this object: run() reads them from inside - // the task. column_symmetry may be null when the solve found none. root_structural_t(problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, - const std::vector>* column_symmetry, - int lane_budget); + structural_incumbent_callback_t incumbent_callback); ~root_structural_t(); - // Name of the heuristic that recognized the model, or nullptr when none did. - const char* recognized_name() const; - bool recognized() const { return active_ != nullptr; } - // Detect, construct, validate and hand the point to B&B. Blocking: the caller supplies the task. void run(); private: problem_t& problem_; typename mip_solver_settings_t::tolerances_t tolerances_; std::atomic& preemption_; + structural_incumbent_callback_t incumbent_callback_; std::unique_ptr> active_; }; diff --git a/cpp/tests/mip/arc_flow_test.cu b/cpp/tests/mip/arc_flow_test.cu index 8220f04430..f21ff98a93 100644 --- a/cpp/tests/mip/arc_flow_test.cu +++ b/cpp/tests/mip/arc_flow_test.cu @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -24,9 +25,6 @@ namespace cuopt::mathematical_optimization::test { namespace { -// Enhanced arc-flow model of two identical parallel machines minimizing weighted completion time. -// A job arc advances one machine's clock from state q to q + p, costs w * q, and covers one unit -// of its job type's demand; a loss arc pads the tail of a machine's horizon. struct job_type_t { int p; int w; @@ -43,23 +41,16 @@ struct built_model_t { std::vector var_lb; std::vector var_ub; std::vector var_types; - int n_rows{0}; - int n_cols{0}; int horizon{0}; - int loss_first{0}; - int job_arcs{0}; // arcs surviving the reduction - int used_states{0}; // states surviving the reduction + int job_arcs{0}; + int used_states{0}; }; struct build_options_t { - // Encode path termination as slack in the conservation row instead of an explicit loss arc, - // which is what Papilo's singleton column substitution produces. bool row_slack_terminators{false}; bool permute{false}; - double flow_row_factor{1.0}; // scale the second flow row and its bounds - // Break the affine cost model by moving one of this type's interior arcs off its own line. Named - // by type rather than by column because which arcs survive the reduction is not obvious outside - // the builder. + double flow_row_factor{1.0}; + double cost_intercept{0.0}; int perturbed_cost_type{-1}; }; @@ -85,9 +76,6 @@ int eaf_loss_first(const std::vector& jobs) return eaf_horizon(jobs) - p_max; } -// Types in Smith order: decreasing weight over processing time, ties by index so the reduction is -// deterministic. An optimal schedule runs each machine's jobs in this order, so a path through the -// graph visits types in this order and no other sequence needs representing. std::vector smith_order(const std::vector& jobs) { std::vector order(jobs.size()); @@ -101,20 +89,12 @@ std::vector smith_order(const std::vector& jobs) return order; } -// The reduced graph Kramer, Dell'Amico and Iori build rather than the straight one. A type may -// only leave a state that a canonical path reaches, meaning one composed of types no later in Smith -// order, which drops both arcs and whole states. Straight arc flow gives every type an arc at -// every feasible start, so the load table alone determines reachability and a search that ignored -// the graph would still pass; here it cannot. struct reduced_graph_t { - std::vector> arcs; // (type, start state) - std::vector states; // used states, ascending - std::vector row_of_state; // state -> row, or -1 when the reduction dropped it + std::vector> arcs; + std::vector states; + std::vector row_of_state; }; -// A machine may be loaded to exactly the horizon, so the states run to it inclusive. Stopping one -// short silently drops the schedules that fill a machine, which are optimal often enough that the -// graph would no longer contain the optimum for the reference to be compared against. reduced_graph_t reduce_eaf(const std::vector& jobs, int horizon, int loss_first) { const int last = horizon; @@ -124,7 +104,6 @@ reduced_graph_t reduce_eaf(const std::vector& jobs, int horizon, int reduced_graph_t graph; for (const int type : smith_order(jobs)) { const int p = jobs[type].p; - // Copies of this type may precede an arc of it, so its own chains extend reachability first. const std::vector before = reachable; for (int q = 0; q <= last; ++q) { if (!before[q]) { continue; } @@ -173,7 +152,6 @@ built_model_t build_eaf(const std::vector& jobs, const build_options return row; }; - // Columns: every surviving job arc, then the loss arcs when they are represented explicitly. struct column_t { std::vector> entries; double cost; @@ -185,7 +163,7 @@ built_model_t build_eaf(const std::vector& jobs, const build_options column_t col; col.entries = { {row_of(start), 1.0}, {row_of(start + jobs[type].p), -1.0}, {n_states + type, 1.0}}; - col.cost = (double)jobs[type].w * start; + col.cost = (double)jobs[type].w * start + opts.cost_intercept; col.ub = jobs[type].d; col.type = type; columns.push_back(std::move(col)); @@ -216,8 +194,6 @@ built_model_t build_eaf(const std::vector& jobs, const build_options for (int c = 0; c < (int)columns.size(); ++c) { if (columns[c].type == opts.perturbed_cost_type) { of_type.push_back(c); } } - // The slope is fitted from the label's extreme arcs, so only an interior arc is off the fitted - // line and reachable solely by the residual check over every arc. EXPECT_GE(of_type.size(), 3u) << "an interior arc needs a type with at least three of them"; columns[of_type[of_type.size() / 2]].cost += 1.0; } @@ -235,10 +211,7 @@ built_model_t build_eaf(const std::vector& jobs, const build_options } built_model_t model; - model.n_rows = n_rows; - model.n_cols = n_cols; model.horizon = horizon; - model.loss_first = loss_first; model.job_arcs = graph.arcs.size(); model.used_states = n_states; model.obj.assign(n_cols, 0.0); @@ -284,13 +257,11 @@ built_model_t build_eaf(const std::vector& jobs, const build_options return model; } -// Optimal schedule by exhaustive assignment. Smith's rule makes weighted shortest processing -// time optimal per machine, so sequencing each machine that way gives the true optimum. double brute_force_optimum(const std::vector& jobs) { const int horizon = eaf_horizon(jobs); const int loss_first = eaf_loss_first(jobs); - std::vector> expanded; // (p, w) + std::vector> expanded; for (const auto& job : jobs) { for (int i = 0; i < job.d; ++i) { expanded.emplace_back(job.p, job.w); @@ -331,27 +302,52 @@ struct run_outcome_t { std::vector assignment; }; -run_outcome_t run_heuristic(const built_model_t& model) +struct input_options_t { + bool set_lower_bounds{true}; + bool set_upper_bounds{true}; +}; + +void expect_feasible(const built_model_t& model, const std::vector& assignment) +{ + ASSERT_EQ(assignment.size(), model.obj.size()); + for (size_t j = 0; j < assignment.size(); ++j) { + EXPECT_GE(assignment[j], model.var_lb[j]); + EXPECT_LE(assignment[j], model.var_ub[j]); + if (model.var_types[j] == var_t::INTEGER) { + EXPECT_DOUBLE_EQ(assignment[j], std::round(assignment[j])); + } + } + for (size_t r = 0; r < model.row_lb.size(); ++r) { + double activity = 0.0; + for (int k = model.offsets[r]; k < model.offsets[r + 1]; ++k) { + activity += model.values[k] * assignment[model.indices[k]]; + } + EXPECT_GE(activity, model.row_lb[r]); + EXPECT_LE(activity, model.row_ub[r]); + } +} + +run_outcome_t run_heuristic(const built_model_t& model, input_options_t options = {}) { const raft::handle_t handle{}; optimization_problem_t problem(&handle); - auto values = model.values; - auto indices = model.indices; - auto offsets = model.offsets; - auto obj = model.obj; - auto var_lb = model.var_lb; - auto var_ub = model.var_ub; - auto types = model.var_types; - auto row_lb = model.row_lb; - auto row_ub = model.row_ub; problem.set_csr_constraint_matrix( - values.data(), values.size(), indices.data(), indices.size(), offsets.data(), offsets.size()); - problem.set_objective_coefficients(obj.data(), obj.size()); - problem.set_variable_lower_bounds(var_lb.data(), var_lb.size()); - problem.set_variable_upper_bounds(var_ub.data(), var_ub.size()); - problem.set_variable_types(types.data(), types.size()); - problem.set_constraint_lower_bounds(row_lb.data(), row_lb.size()); - problem.set_constraint_upper_bounds(row_ub.data(), row_ub.size()); + model.values.data(), + model.values.size(), + model.indices.data(), + model.indices.size(), + model.offsets.data(), + model.offsets.size()); + problem.set_objective_coefficients(model.obj.data(), model.obj.size()); + if (options.set_lower_bounds) { + problem.set_variable_lower_bounds(model.var_lb.data(), model.var_lb.size()); + } + if (options.set_upper_bounds) { + problem.set_variable_upper_bounds(model.var_ub.data(), model.var_ub.size()); + } + problem.set_variable_types(model.var_types.data(), model.var_types.size()); + problem.set_constraint_lower_bounds(model.row_lb.data(), model.row_lb.size()); + problem.set_constraint_upper_bounds(model.row_ub.data(), model.row_ub.size()); mip_solver_settings_t settings; run_outcome_t outcome; @@ -360,13 +356,11 @@ run_outcome_t run_heuristic(const built_model_t& model) if (!outcome.prescreened) { return outcome; } std::atomic preemption{false}; - const auto status = - heuristic.solve(settings.get_tolerances(), preemption, 0.0, outcome.assignment); + const auto status = heuristic.solve(settings.get_tolerances(), preemption, outcome.assignment); outcome.found = status == mip::structural_outcome_t::constructed; outcome.exact = heuristic.search_was_exact(); - // The dispatcher would take this from the solver-space problem; the models here are minimize - // with no offset, so the two agree. if (outcome.found) { + expect_feasible(model, outcome.assignment); outcome.objective = 0.0; for (size_t j = 0; j < outcome.assignment.size(); ++j) { outcome.objective += model.obj[j] * outcome.assignment[j]; @@ -381,16 +375,12 @@ const std::vector& small_instance() return jobs; } -// Processing times that do not tile the horizon, so the reduction leaves gaps: states 1, 3, 8 and -// 13 are unreachable by any canonical path and are absent from the model entirely. const std::vector& gapped_instance() { static const std::vector jobs = {{2, 9, 2}, {5, 4, 2}, {7, 3, 1}}; return jobs; } -// The heaviest type leads the Smith order and fills six of nine units, so the reduction leaves it a -// single arc out of the source and its cost slope has no second point to be fitted from. const std::vector& single_arc_label_instance() { static const std::vector jobs = {{2, 1, 1}, {5, 1, 1}, {6, 4, 1}}; @@ -406,14 +396,9 @@ TEST(arc_flow, matches_brute_force_optimum) ASSERT_TRUE(outcome.prescreened); ASSERT_TRUE(outcome.found); EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); - // A search this small is nowhere near the history budget, so beaming it would mean the budget - // is being converted into a width up front instead of charged as it accumulates. EXPECT_TRUE(outcome.exact); } -// The reduction is what separates the enhanced graph from the straight one, so the fixture has to -// exercise it: a graph with an arc at every feasible start makes reachability a function of the -// load alone, and a search that never consulted the arc set would pass anyway. TEST(arc_flow, reduced_graph_omits_states_and_arcs) { const auto model = build_eaf(gapped_instance(), {}); @@ -425,8 +410,6 @@ TEST(arc_flow, reduced_graph_omits_states_and_arcs) EXPECT_LT(model.used_states, model.horizon + 1) << "the reduction dropped no state"; } -// Normal patterns keep at least one optimal schedule, so the reduced graph must still reach the -// optimum the reference finds by exhaustive assignment. TEST(arc_flow, matches_brute_force_optimum_on_reduced_graph) { const auto outcome = run_heuristic(build_eaf(gapped_instance(), {})); @@ -435,9 +418,6 @@ TEST(arc_flow, matches_brute_force_optimum_on_reduced_graph) EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(gapped_instance())); } -// A label with one arc is ordered by where that arc can go rather than by its ratio, which is a -// position the model determines but not the Smith one. The point stays usable; what must not -// happen is the pass reporting it as the optimum of an order it did not actually search. TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) { const auto outcome = run_heuristic(build_eaf(single_arc_label_instance(), {})); @@ -447,8 +427,6 @@ TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(single_arc_label_instance())); } -// The detector reads only permutation invariant data, so reordering rows and columns must not -// change what it finds. This is the property that keeps it from leaning on model index order. TEST(arc_flow, invariant_under_row_and_column_permutation) { build_options_t permuted; @@ -460,8 +438,6 @@ TEST(arc_flow, invariant_under_row_and_column_permutation) EXPECT_DOUBLE_EQ(plain.objective, shuffled.objective); } -// Papilo substitutes a bounded singleton column out of its equality and leaves the row as an -// inequality, so a loss arc reaches the second early heuristic slot as conservation row slack. TEST(arc_flow, accepts_row_slack_terminators) { build_options_t slack; @@ -472,8 +448,6 @@ TEST(arc_flow, accepts_row_slack_terminators) EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); } -// MIP scaling applies power of two row factors before this heuristic runs, so the unit incidence -// pattern is only recoverable after normalizing each row by its coefficient magnitude. TEST(arc_flow, tolerates_row_scaling) { build_options_t scaled; @@ -486,17 +460,12 @@ TEST(arc_flow, tolerates_row_scaling) TEST(arc_flow, rejects_non_affine_costs) { - // The slope is fitted from the label's extreme arcs, so moving one arc off the line is only - // caught by the residual check that revisits every arc. build_options_t perturbed; perturbed.perturbed_cost_type = 1; const auto outcome = run_heuristic(build_eaf(small_instance(), perturbed)); EXPECT_FALSE(outcome.found); } -// The construction consumes exactly the demanded units, so it cannot discover that oversatisfying -// a covering row pays. A negative cost slope is where that would happen, and the detector has to -// refuse the model rather than return a point it has no argument for. TEST(arc_flow, rejects_negative_cost_slope) { const std::vector jobs = {{1, 3, 2}, {2, -1, 1}, {3, 2, 1}}; @@ -507,8 +476,6 @@ TEST(arc_flow, rejects_negative_cost_slope) TEST(arc_flow, rejects_model_without_unit_incidence) { built_model_t knapsack; - knapsack.n_rows = 1; - knapsack.n_cols = 2; knapsack.values = {2.0, 3.0}; knapsack.indices = {0, 1}; knapsack.offsets = {0, 2}; @@ -523,6 +490,42 @@ TEST(arc_flow, rejects_model_without_unit_incidence) EXPECT_FALSE(outcome.found); } +TEST(arc_flow, accepts_implicit_zero_lower_bounds) +{ + input_options_t options; + options.set_lower_bounds = false; + const auto outcome = run_heuristic(build_eaf(small_instance(), {}), options); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); +} + +TEST(arc_flow, rejects_implicit_infinite_upper_bounds) +{ + input_options_t options; + options.set_upper_bounds = false; + const auto outcome = run_heuristic(build_eaf(small_instance(), {}), options); + EXPECT_FALSE(outcome.prescreened); + EXPECT_FALSE(outcome.found); +} + +TEST(arc_flow, accepts_large_finite_capacities) +{ + auto model = build_eaf(small_instance(), {}); + std::fill(model.var_ub.begin(), model.var_ub.end(), std::numeric_limits::max()); + const auto outcome = run_heuristic(model); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); +} + +TEST(arc_flow, accepts_affine_cost_intercept) +{ + build_options_t options; + options.cost_intercept = 7.0; + const auto outcome = run_heuristic(build_eaf(small_instance(), options)); + ASSERT_TRUE(outcome.prescreened); + ASSERT_TRUE(outcome.found); +} + TEST(arc_flow, is_reproducible) { const auto model = build_eaf(small_instance(), {}); diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index aa488e064b..d6a363e19d 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -170,6 +170,7 @@ cuopt/ ### CUDA/GPU Hygiene - Keep operations stream-ordered - Follow existing RAFT/RMM patterns +- `rmm::device_uvector` tests emptiness with `is_empty()`; it has no `empty()` member. - No raw `new`/`delete` - use RMM allocators ## Build & Test From e59508741d04f00ea0951f0b35b3339e7e9f7950 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 31 Aug 2026 08:31:56 -0700 Subject: [PATCH 3/7] refactoring efforts --- cpp/src/mip_heuristics/solve.cu | 8 +- cpp/src/mip_heuristics/solver.cu | 7 + cpp/src/mip_heuristics/structural/arc_flow.cu | 210 ++++----- .../mip_heuristics/structural/arc_flow.cuh | 8 +- .../structural/early_structural.cu | 93 ++-- .../structural/early_structural.cuh | 9 +- cpp/tests/mip/arc_flow_test.cu | 424 ++++++------------ skills/cuopt-developer/SKILL.md | 2 + 8 files changed, 286 insertions(+), 475 deletions(-) diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index 7ed3a6a0ca..378ed08db3 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -284,7 +284,6 @@ mip_solution_t run_mip_solver( f_t user_obj, const std::vector& assignment, const char* heuristic_name) { - // Both producers are built on *problem.original_problem_ptr; solver_obj shares one space. std::lock_guard lock(papilo_callback_mutex); if (solver_obj >= papilo_best_solver_obj) { return; } papilo_best_solver_obj = solver_obj; @@ -685,9 +684,10 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p if (early_structural) { early_structural->stop(); if (early_structural->solution_found()) { - CUOPT_LOG_DEBUG("Early structural heuristic (original) found incumbent with objective %.6e " - "during presolve", - early_structural->get_best_objective()); + CUOPT_LOG_DEBUG( + "Early structural heuristic (original) found incumbent with objective %.6e " + "during presolve", + early_structural->get_best_objective()); } early_structural.reset(); } diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index 9ee2f29be9..5214f08b73 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -526,6 +526,13 @@ solution_t mip_solver_t::run_solver() sol = dm.run_solver(); } // implicit barrier for all tasks created in B&B and heuristics + dm.population.add_external_solutions_to_population(); + if (dm.population.is_feasible() && + (!sol.get_feasible() || + dm.population.best_feasible().get_objective() < sol.get_objective())) { + sol = solution_t(dm.population.best_feasible()); + } + if (!context.settings.heuristics_only && branch_and_bound->has_solver_space_incumbent()) { solution_t branch_and_bound_sol(*context.problem_ptr); branch_and_bound_sol.copy_new_assignment(branch_and_bound_solution.x); diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index f68f279ead..dd23155162 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -102,7 +102,7 @@ bool close_to(double a, double b, double scale, const arcflow_tol_t& tol) bool is_integral(double v, const arcflow_tol_t& tol) { - return std::abs(v - std::round(v)) <= tol.abs; + return std::abs(v - std::round(v)) <= tol.abs + tol.rel * std::abs(v); } bool is_known(double v) { return !std::isnan(v); } @@ -610,9 +610,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti if (p <= tol.abs) { return false; } } - // The construction consumes exactly the demanded number of tokens per label, so it never - // oversatisfies a covering row. That is only cost preserving when covering more cannot pay, - // and the weighted completion time argument behind the token order needs the same condition. + // Smith ordering assumes nonnegative job weights. for (double w : model.slope) { if (is_known(w) && w < -tol.abs) { return false; } } @@ -637,7 +635,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti } // Weighted shortest processing time orders labels by decreasing slope over displacement. -std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact) +std::vector token_order(const arc_flow_model_t& model, bool& all_slopes_identified) { std::vector lowest_phi(model.n_labels, 0.0); for (int l = 0; l < model.n_labels; ++l) { @@ -661,25 +659,28 @@ std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact const arcflow_tol_t tol = model.tol; const auto cross = [&](int a, int b) { - return (long double)model.slope[a] * (long double)model.displacement[b]; + return (_Float128)model.slope[a] * (_Float128)model.displacement[b]; }; // Approximate equality is not transitive. Tolerance forms ratio classes before the final sort. std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { - const long double lhs = cross(a, b); - const long double rhs = cross(b, a); + const _Float128 lhs = cross(a, b); + const _Float128 rhs = cross(b, a); if (lhs != rhs) { return lhs > rhs; } return a < b; }); std::vector ratio_class(model.n_labels, 0); for (size_t position = 1; position < ordered.size(); ++position) { - const int previous = ordered[position - 1]; - const int current = ordered[position]; - const long double lhs = cross(previous, current); - const long double rhs = cross(current, previous); - const long double scale = std::max(std::abs(lhs), std::abs(rhs)); - const bool tied = std::abs(lhs - rhs) <= tol.abs + tol.rel * (double)scale; - ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); + const int previous = ordered[position - 1]; + const int current = ordered[position]; + const _Float128 lhs = cross(previous, current); + const _Float128 rhs = cross(current, previous); + const _Float128 lhs_magnitude = lhs < 0 ? -lhs : lhs; + const _Float128 rhs_magnitude = rhs < 0 ? -rhs : rhs; + const _Float128 scale = std::max(lhs_magnitude, rhs_magnitude); + const _Float128 difference = lhs > rhs ? lhs - rhs : rhs - lhs; + const bool tied = difference <= (_Float128)tol.abs + (_Float128)tol.rel * scale; + ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); } std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { if (ratio_class[a] != ratio_class[b]) { return ratio_class[a] < ratio_class[b]; } @@ -701,7 +702,7 @@ std::vector token_order(const arc_flow_model_t& model, bool& ordering_exact } // Labels without fitted slopes are placed at their first reachable potential. - ordering_exact = unidentified.empty(); + all_slopes_identified = unidentified.empty(); std::sort(unidentified.begin(), unidentified.end(), [&](int a, int b) { if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } return a < b; @@ -886,6 +887,11 @@ std::optional run_dp(const arc_flow_model_t& model, template struct arc_flow_t::host_state_t { + host_state_t(host_problem_t&& problem, arcflow_profile_t&& profile) + : h(std::move(problem)), profile(std::move(profile)) + { + } + host_problem_t h; arcflow_profile_t profile; }; @@ -905,66 +911,52 @@ bool arc_flow_t::recognize(const optimization_problem_t& op_ if (!arcflow_accepts_shape(n_variables, n_constraints, op_problem.get_nnz())) { return false; } if (op_problem.get_n_integers() != n_variables) { return false; } - auto stream = op_problem.get_handle_ptr()->get_stream(); - const auto& d_row_lb = op_problem.get_constraint_lower_bounds(); - const auto& d_row_ub = op_problem.get_constraint_upper_bounds(); + auto stream = op_problem.get_handle_ptr()->get_stream(); + const auto& d_row_lb = op_problem.get_constraint_lower_bounds(); + const auto& d_row_ub = op_problem.get_constraint_upper_bounds(); + const auto& d_values = op_problem.get_constraint_matrix_values(); + const auto& d_indices = op_problem.get_constraint_matrix_indices(); + const auto& d_offsets = op_problem.get_constraint_matrix_offsets(); + const auto& d_obj = op_problem.get_objective_coefficients(); + const auto& d_var_lb = op_problem.get_variable_lower_bounds(); + const auto& d_var_ub = op_problem.get_variable_upper_bounds(); + const auto& d_var_types = op_problem.get_variable_types(); if ((i_t)d_row_lb.size() != n_constraints || (i_t)d_row_ub.size() != n_constraints) { return false; } - std::vector row_lb(n_constraints); - std::vector row_ub(n_constraints); - raft::copy(row_lb.data(), d_row_lb.data(), row_lb.size(), stream); - raft::copy(row_ub.data(), d_row_ub.data(), row_ub.size(), stream); - stream.synchronize(); - if (!arcflow_accepts_bounds(row_lb, row_ub)) { return false; } - - const auto& d_values = op_problem.get_constraint_matrix_values(); - const auto& d_indices = op_problem.get_constraint_matrix_indices(); - const auto& d_offsets = op_problem.get_constraint_matrix_offsets(); if ((i_t)d_offsets.size() != n_constraints + 1) { return false; } if (d_values.size() != d_indices.size()) { return false; } - std::vector values(d_values.size()); - std::vector indices(d_indices.size()); - std::vector offsets(d_offsets.size()); - raft::copy(values.data(), d_values.data(), values.size(), stream); - raft::copy(indices.data(), d_indices.data(), indices.size(), stream); - raft::copy(offsets.data(), d_offsets.data(), offsets.size(), stream); - stream.synchronize(); - - const arcflow_tol_t tol = structural_tolerance(); - auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); - if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } - - const auto& d_obj = op_problem.get_objective_coefficients(); - const auto& d_var_lb = op_problem.get_variable_lower_bounds(); - const auto& d_var_ub = op_problem.get_variable_upper_bounds(); - const auto& d_var_types = op_problem.get_variable_types(); cuopt_assert((i_t)d_obj.size() == n_variables, "Size mismatch"); cuopt_assert((i_t)d_var_types.size() == n_variables, "Size mismatch"); if (!d_var_lb.is_empty() && (i_t)d_var_lb.size() != n_variables) { return false; } if ((i_t)d_var_ub.size() != n_variables) { return false; } - auto state = std::make_unique(); - state->h.n_variables = n_variables; - state->h.n_constraints = n_constraints; - state->h.tol = tol; - state->h.row_lb = std::move(row_lb); - state->h.row_ub = std::move(row_ub); - state->h.obj.resize(d_obj.size()); - state->h.var_lb.assign(n_variables, f_t{0}); - state->h.var_ub.resize(d_var_ub.size()); - state->h.var_types.resize(d_var_types.size()); - raft::copy(state->h.obj.data(), d_obj.data(), state->h.obj.size(), stream); - if (!d_var_lb.is_empty()) { - raft::copy(state->h.var_lb.data(), d_var_lb.data(), state->h.var_lb.size(), stream); + host_problem_t h; + h.n_variables = n_variables; + h.n_constraints = n_constraints; + h.tol = structural_tolerance(); + h.row_lb = cuopt::host_copy(d_row_lb, stream); + h.row_ub = cuopt::host_copy(d_row_ub, stream); + if (!arcflow_accepts_bounds(h.row_lb, h.row_ub)) { return false; } + + const auto values = cuopt::host_copy(d_values, stream); + const auto indices = cuopt::host_copy(d_indices, stream); + const auto offsets = cuopt::host_copy(d_offsets, stream); + auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); + if (!arcflow_accepts_profile(profile, h.row_lb, h.row_ub, h.tol)) { return false; } + + h.obj = cuopt::host_copy(d_obj, stream); + if (op_problem.get_sense()) { + for (auto& coefficient : h.obj) { + coefficient = -coefficient; + } } - raft::copy(state->h.var_ub.data(), d_var_ub.data(), state->h.var_ub.size(), stream); - raft::copy(state->h.var_types.data(), d_var_types.data(), state->h.var_types.size(), stream); - stream.synchronize(); - transpose_into_csc(values, indices, offsets, state->h); - state->profile = std::move(profile); - - state_ = std::move(state); + h.var_lb.assign(n_variables, f_t{0}); + if (!d_var_lb.is_empty()) { h.var_lb = cuopt::host_copy(d_var_lb, stream); } + h.var_ub = cuopt::host_copy(d_var_ub, stream); + h.var_types = cuopt::host_copy(d_var_types, stream); + transpose_into_csc(values, indices, offsets, h); + state_ = std::make_unique(std::move(h), std::move(profile)); return true; } @@ -980,57 +972,37 @@ bool arc_flow_t::recognize(const problem_t& problem, auto stream = problem.handle_ptr->get_stream(); cuopt_assert((i_t)problem.constraint_lower_bounds.size() == n_constraints, "Size mismatch"); cuopt_assert((i_t)problem.constraint_upper_bounds.size() == n_constraints, "Size mismatch"); - std::vector row_lb(n_constraints); - std::vector row_ub(n_constraints); - raft::copy(row_lb.data(), problem.constraint_lower_bounds.data(), row_lb.size(), stream); - raft::copy(row_ub.data(), problem.constraint_upper_bounds.data(), row_ub.size(), stream); - stream.synchronize(); - if (!arcflow_accepts_bounds(row_lb, row_ub)) { return false; } - - std::vector csc_values(problem.reverse_coefficients.size()); - std::vector csc_rows(problem.reverse_constraints.size()); - std::vector csc_offsets(problem.reverse_offsets.size()); - raft::copy(csc_values.data(), problem.reverse_coefficients.data(), csc_values.size(), stream); - raft::copy(csc_rows.data(), problem.reverse_constraints.data(), csc_rows.size(), stream); - raft::copy(csc_offsets.data(), problem.reverse_offsets.data(), csc_offsets.size(), stream); - stream.synchronize(); - cuopt_assert((i_t)csc_offsets.size() == n_variables + 1, "Size mismatch"); - cuopt_assert(csc_values.size() == csc_rows.size(), "Size mismatch"); - - const arcflow_tol_t tol = structural_tolerance(); - auto profile = - profile_from_csc(n_variables, n_constraints, csc_values, csc_rows, csc_offsets); - if (!arcflow_accepts_profile(profile, row_lb, row_ub, tol)) { return false; } - + cuopt_assert((i_t)problem.reverse_offsets.size() == n_variables + 1, "Size mismatch"); + cuopt_assert(problem.reverse_coefficients.size() == problem.reverse_constraints.size(), + "Size mismatch"); cuopt_assert((i_t)problem.objective_coefficients.size() == n_variables, "Size mismatch"); cuopt_assert((i_t)problem.variable_types.size() == n_variables, "Size mismatch"); - auto state = std::make_unique(); - state->h.n_variables = n_variables; - state->h.n_constraints = n_constraints; - state->h.tol = tol; - state->h.csc_values = std::move(csc_values); - state->h.csc_rows = std::move(csc_rows); - state->h.csc_offsets = std::move(csc_offsets); - state->h.row_lb = std::move(row_lb); - state->h.row_ub = std::move(row_ub); - state->h.obj.resize(problem.objective_coefficients.size()); - state->h.var_types.resize(problem.variable_types.size()); - raft::copy( - state->h.obj.data(), problem.objective_coefficients.data(), state->h.obj.size(), stream); - raft::copy( - state->h.var_types.data(), problem.variable_types.data(), state->h.var_types.size(), stream); - stream.synchronize(); - std::tie(state->h.var_lb, state->h.var_ub) = - cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); - state->profile = std::move(profile); + host_problem_t h; + h.n_variables = n_variables; + h.n_constraints = n_constraints; + h.tol = structural_tolerance(); + h.csc_values = cuopt::host_copy(problem.reverse_coefficients, stream); + h.csc_rows = cuopt::host_copy(problem.reverse_constraints, stream); + h.csc_offsets = cuopt::host_copy(problem.reverse_offsets, stream); + h.row_lb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + h.row_ub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + if (!arcflow_accepts_bounds(h.row_lb, h.row_ub)) { return false; } + + auto profile = + profile_from_csc(n_variables, n_constraints, h.csc_values, h.csc_rows, h.csc_offsets); + if (!arcflow_accepts_profile(profile, h.row_lb, h.row_ub, h.tol)) { return false; } - state_ = std::move(state); + h.obj = cuopt::host_copy(problem.objective_coefficients, stream); + h.var_types = cuopt::host_copy(problem.variable_types, stream); + std::tie(h.var_lb, h.var_ub) = + cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); + state_ = std::make_unique(std::move(h), std::move(profile)); return true; } template -structural_outcome_t arc_flow_t::solve( +bool arc_flow_t::solve( const typename mip_solver_settings_t::tolerances_t&, std::atomic& preemption, std::vector& assignment) @@ -1041,38 +1013,36 @@ structural_outcome_t arc_flow_t::solve( std::vector rows; if (!classify_rows(h, state_->profile, rows)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: rows are not unit incidence after normalization"); - return structural_outcome_t::declined; + return false; } arc_flow_model_t model; if (!build_structure(h, rows, model)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: columns do not match the labelled arc pattern"); - return structural_outcome_t::declined; + return false; } - if (preemption.load()) { return structural_outcome_t::declined; } + if (preemption.load()) { return false; } if (!derive_potential(model, preemption)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: no consistent potential and affine cost model"); - return structural_outcome_t::declined; + return false; } - if (preemption.load()) { return structural_outcome_t::declined; } + if (preemption.load()) { return false; } - bool ordering_exact = true; - const auto tokens = token_order(model, ordering_exact); + bool all_slopes_identified = true; + const auto tokens = token_order(model, all_slopes_identified); CUOPT_LOG_DEBUG("[ArcFlow] detected %d nodes, %d labels, %d paths, %zu tokens, ordering %s", model.n_nodes, model.n_labels, arcflow_paths_supported, tokens.size(), - ordering_exact ? "identified" : "partly by reachability"); + all_slopes_identified ? "identified" : "partly by reachability"); const auto result = run_dp(model, tokens, preemption); if (!result.has_value()) { CUOPT_LOG_DEBUG("[ArcFlow] no complete path set found in the ordered family"); - return structural_outcome_t::declined; + return false; } - // Unidentified token order makes an otherwise complete search heuristic. - search_was_exact_ = result->exact && ordering_exact; CUOPT_LOG_DEBUG( "[ArcFlow] search %s", result->exact ? "exact" : "beamed by the history budget"); @@ -1080,7 +1050,7 @@ structural_outcome_t arc_flow_t::solve( for (int col : result->columns) { assignment[col] += f_t{1}; } - return structural_outcome_t::constructed; + return true; } #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cuh b/cpp/src/mip_heuristics/structural/arc_flow.cuh index 239f886508..11ae4d554a 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cuh +++ b/cpp/src/mip_heuristics/structural/arc_flow.cuh @@ -26,21 +26,15 @@ class arc_flow_t : public structural_heuristic_t { bool recognize(const problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances) override; - structural_outcome_t solve( + bool solve( const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, std::vector& assignment) override; - // True only when the constructed point is the optimum of the Smith-ordered family: false if the - // history budget forced a beam, and false if any label had to be ordered by reachability because - // its slope could not be fitted. - bool search_was_exact() const { return search_was_exact_; } - private: struct host_state_t; std::unique_ptr state_; - bool search_was_exact_{true}; }; } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/structural/early_structural.cu b/cpp/src/mip_heuristics/structural/early_structural.cu index 95b0810198..0b0269cdb7 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cu +++ b/cpp/src/mip_heuristics/structural/early_structural.cu @@ -11,71 +11,40 @@ #include #include -#include #include #include -#include #include namespace cuopt::mathematical_optimization::mip { template -static bool validate(const problem_t& problem, +static bool validate(problem_t& problem, const std::vector& assignment, f_t& objective) { - auto stream = problem.handle_ptr->get_stream(); - - const auto csr_values = cuopt::host_copy(problem.coefficients, stream); - const auto csr_cols = cuopt::host_copy(problem.variables, stream); - const auto csr_offsets = cuopt::host_copy(problem.offsets, stream); - const auto row_lb = cuopt::host_copy(problem.constraint_lower_bounds, stream); - const auto row_ub = cuopt::host_copy(problem.constraint_upper_bounds, stream); - const auto obj = cuopt::host_copy(problem.objective_coefficients, stream); - const auto var_types = cuopt::host_copy(problem.variable_types, stream); - const auto [var_lb, var_ub] = - cuopt::extract_host_bounds(problem.variable_bounds, problem.handle_ptr); - if ((i_t)assignment.size() != problem.n_variables) { return false; } - - const double integrality = problem.tolerances.integrality_tolerance; - - double obj_value = 0.0; - for (i_t j = 0; j < problem.n_variables; ++j) { - const double x = assignment[j]; - if (!std::isfinite(x)) { return false; } - if (var_types[j] == var_t::INTEGER && std::abs(x - std::round(x)) > integrality) { - return false; - } - if (x < (double)var_lb[j] - integrality || x > (double)var_ub[j] + integrality) { - return false; - } - obj_value += obj[j] * x; - } - if (!std::isfinite(obj_value)) { return false; } - - for (i_t r = 0; r < problem.n_constraints; ++r) { - double activity = 0.0; - for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { - activity += (double)csr_values[k] * (double)assignment[csr_cols[k]]; - } - if (!std::isfinite(activity)) { return false; } - const double lo = row_lb[r]; - const double hi = row_ub[r]; - const double slack = get_cstr_tolerance(lo, - hi, - problem.tolerances.absolute_tolerance, - problem.tolerances.relative_tolerance); - if (std::isfinite(lo) && activity < lo - slack) { return false; } - if (std::isfinite(hi) && activity > hi + slack) { return false; } + solution_t solution(problem); + solution.copy_new_assignment(assignment); + if (has_variable_bounds_violation(problem.handle_ptr, solution.assignment, &problem) || + !solution.compute_feasibility()) { + return false; } - - objective = obj_value; + objective = solution.get_objective(); return true; } +template +static std::unique_ptr> make_structural_heuristic( + const model_t& model, + const typename mip_solver_settings_t::tolerances_t& tolerances) +{ + auto heuristic = std::make_unique>(); + if (!heuristic->recognize(model, tolerances)) { return nullptr; } + return heuristic; +} + template std::unique_ptr> early_structural_t::create( const optimization_problem_t& op_problem, @@ -83,8 +52,8 @@ std::unique_ptr> early_structural_t::crea early_incumbent_callback_t incumbent_callback) { if (omp_get_num_threads() < CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT) { return nullptr; } - auto active = std::make_unique>(); - if (!active->recognize(op_problem, tolerances)) { return nullptr; } + auto active = make_structural_heuristic(op_problem, tolerances); + if (!active) { return nullptr; } return std::unique_ptr( new early_structural_t(op_problem, tolerances, std::move(incumbent_callback), std::move(active))); } @@ -164,8 +133,7 @@ void early_structural_t::run() cuopt_assert(active_ != nullptr, "task launched without a recognized structure"); std::vector assignment; - const structural_outcome_t outcome = active_->solve(tolerances_, preemption_flag_, assignment); - if (outcome != structural_outcome_t::constructed) { + if (!active_->solve(tolerances_, preemption_flag_, assignment)) { CUOPT_LOG_DEBUG("[Early Structural] %s constructed nothing", active_->name()); return; } @@ -193,14 +161,16 @@ root_structural_t::root_structural_t( const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, structural_incumbent_callback_t incumbent_callback) - : problem_(problem), - tolerances_(tolerances), + : tolerances_(tolerances), preemption_(preemption), incumbent_callback_(std::move(incumbent_callback)) { - auto arc_flow = std::make_unique>(); - if (arc_flow->recognize(problem, tolerances)) { active_ = std::move(arc_flow); } - if (active_) { CUOPT_LOG_DEBUG("[Root Structural] %s recognized the model", active_->name()); } + RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); + active_ = make_structural_heuristic(problem, tolerances); + if (!active_) { return; } + problem.handle_ptr->sync_stream(); + problem_ = std::make_unique>(problem, &handle_); + CUOPT_LOG_DEBUG("[Root Structural] %s recognized the model", active_->name()); } template @@ -210,18 +180,19 @@ template void root_structural_t::run() { if (!active_) { return; } + cuopt_assert(problem_ != nullptr, "missing structural problem"); cuopt_assert(incumbent_callback_ != nullptr, "missing incumbent callback"); std::vector assignment; - const structural_outcome_t outcome = active_->solve(tolerances_, preemption_, assignment); - if (outcome != structural_outcome_t::constructed) { + if (!active_->solve(tolerances_, preemption_, assignment)) { CUOPT_LOG_DEBUG("[Root Structural] %s constructed nothing", active_->name()); return; } if (preemption_.load()) { return; } + RAFT_CUDA_TRY(cudaSetDevice(device_id_)); f_t objective{0}; - if (!validate(problem_, assignment, objective)) { + if (!validate(*problem_, assignment, objective)) { CUOPT_LOG_DEBUG("[Root Structural] %s constructed a point that failed validation, discarding", active_->name()); return; @@ -231,7 +202,7 @@ void root_structural_t::run() incumbent_callback_(assignment, objective); CUOPT_LOG_DEBUG("[Root Structural] %s queued objective %+.6e", active_->name(), - (double)problem_.get_user_obj_from_solver_obj(objective)); + (double)problem_->get_user_obj_from_solver_obj(objective)); } #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/structural/early_structural.cuh b/cpp/src/mip_heuristics/structural/early_structural.cuh index f9cfe0c90d..aac3447e79 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cuh +++ b/cpp/src/mip_heuristics/structural/early_structural.cuh @@ -10,14 +10,11 @@ #include #include -#include #include #include namespace cuopt::mathematical_optimization::mip { -enum class structural_outcome_t : uint8_t { declined, constructed }; - template class structural_heuristic_t { public: @@ -35,7 +32,7 @@ class structural_heuristic_t { return false; } - virtual structural_outcome_t solve( + virtual bool solve( const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, std::vector& assignment) = 0; @@ -92,7 +89,9 @@ class root_structural_t { void run(); private: - problem_t& problem_; + int device_id_{0}; + raft::handle_t handle_; + std::unique_ptr> problem_; typename mip_solver_settings_t::tolerances_t tolerances_; std::atomic& preemption_; structural_incumbent_callback_t incumbent_callback_; diff --git a/cpp/tests/mip/arc_flow_test.cu b/cpp/tests/mip/arc_flow_test.cu index f21ff98a93..e7ed1fbce6 100644 --- a/cpp/tests/mip/arc_flow_test.cu +++ b/cpp/tests/mip/arc_flow_test.cu @@ -25,12 +25,6 @@ namespace cuopt::mathematical_optimization::test { namespace { -struct job_type_t { - int p; - int w; - int d; -}; - struct built_model_t { std::vector values; std::vector indices; @@ -41,161 +35,83 @@ struct built_model_t { std::vector var_lb; std::vector var_ub; std::vector var_types; - int horizon{0}; - int job_arcs{0}; - int used_states{0}; }; struct build_options_t { bool row_slack_terminators{false}; bool permute{false}; + bool large_finite_capacities{false}; double flow_row_factor{1.0}; double cost_intercept{0.0}; - int perturbed_cost_type{-1}; + int perturbed_cost_label{-1}; + int negative_cost_label{-1}; + int single_arc_label{-1}; }; -constexpr int n_machines = 2; - -int eaf_horizon(const std::vector& jobs) -{ - int total = 0; - int p_max = 0; - for (const auto& job : jobs) { - total += job.p * job.d; - p_max = std::max(p_max, job.p); - } - return (total + (n_machines - 1) * p_max) / n_machines; -} - -int eaf_loss_first(const std::vector& jobs) -{ - int p_max = 0; - for (const auto& job : jobs) { - p_max = std::max(p_max, job.p); - } - return eaf_horizon(jobs) - p_max; -} - -std::vector smith_order(const std::vector& jobs) -{ - std::vector order(jobs.size()); - std::iota(order.begin(), order.end(), 0); - std::stable_sort(order.begin(), order.end(), [&](int a, int b) { - const long lhs = (long)jobs[a].w * jobs[b].p; - const long rhs = (long)jobs[b].w * jobs[a].p; - if (lhs != rhs) { return lhs > rhs; } - return a < b; - }); - return order; -} +enum state_t : int { t0, t1, t2, t3, t5, n_states }; -struct reduced_graph_t { - std::vector> arcs; - std::vector states; - std::vector row_of_state; +struct arc_t { + state_t from; + state_t to; + int label; + double cost; }; -reduced_graph_t reduce_eaf(const std::vector& jobs, int horizon, int loss_first) +constexpr int n_labels = 3; +constexpr int n_paths = 2; +constexpr double expected_objective = 6.0; +constexpr std::array label_demand{2, 1, 1}; +constexpr int demanded_arcs = label_demand[0] + label_demand[1] + label_demand[2]; +constexpr std::array terminator_states{t2, t3, t5}; +constexpr std::array arcs{{{t0, t1, 0, 0.0}, + {t1, t2, 0, 3.0}, + {t2, t3, 0, 6.0}, + {t0, t3, 1, 0.0}, + {t2, t5, 1, 4.0}, + {t0, t2, 2, 0.0}, + {t1, t3, 2, 1.0}, + {t3, t5, 2, 3.0}}}; + +built_model_t build_arc_flow(const build_options_t& opts = {}) { - const int last = horizon; - std::vector reachable(last + 1, 0); - reachable[0] = 1; - - reduced_graph_t graph; - for (const int type : smith_order(jobs)) { - const int p = jobs[type].p; - const std::vector before = reachable; - for (int q = 0; q <= last; ++q) { - if (!before[q]) { continue; } - for (int copies = 1; copies <= jobs[type].d; ++copies) { - const int target = q + copies * p; - if (target > last) { break; } - reachable[target] = 1; - } - } - for (int q = 0; q + p <= last; ++q) { - if (reachable[q]) { graph.arcs.push_back({type, q}); } - } - } - - std::vector used(last + 1, 0); - used[0] = 1; - for (const auto& [type, start] : graph.arcs) { - used[start] = 1; - used[start + jobs[type].p] = 1; - } - for (int q = loss_first; q <= last; ++q) { - if (reachable[q]) { used[q] = 1; } - } - graph.row_of_state.assign(last + 1, -1); - for (int q = 0; q <= last; ++q) { - if (!used[q]) { continue; } - graph.row_of_state[q] = graph.states.size(); - graph.states.push_back(q); - } - return graph; -} - -built_model_t build_eaf(const std::vector& jobs, const build_options_t& opts) -{ - const int horizon = eaf_horizon(jobs); - const int loss_first = eaf_loss_first(jobs); - EXPECT_GT(loss_first, 0) << "the source state must not also be a terminator"; - - const int n_types = jobs.size(); - const reduced_graph_t graph = reduce_eaf(jobs, horizon, loss_first); - const int n_states = graph.states.size(); - const int n_rows = n_states + n_types; - const auto row_of = [&](int state) { - const int row = graph.row_of_state[state]; - EXPECT_GE(row, 0) << "an arc referenced a state the reduction dropped"; - return row; - }; - struct column_t { std::vector> entries; double cost; double ub; - int type{-1}; }; + std::vector columns; - for (const auto& [type, start] : graph.arcs) { - column_t col; - col.entries = { - {row_of(start), 1.0}, {row_of(start + jobs[type].p), -1.0}, {n_states + type, 1.0}}; - col.cost = (double)jobs[type].w * start + opts.cost_intercept; - col.ub = jobs[type].d; - col.type = type; - columns.push_back(std::move(col)); + for (const auto& arc : arcs) { + if (arc.label == opts.single_arc_label && arc.from != t0) { continue; } + double cost = arc.cost + opts.cost_intercept; + if (arc.label == opts.negative_cost_label) { cost = -arc.cost; } + if (arc.label == opts.perturbed_cost_label && arc.from == t1) { cost += 1.0; } + const double ub = opts.large_finite_capacities ? std::numeric_limits::max() + : label_demand[arc.label]; + columns.push_back( + column_t{{{arc.from, 1.0}, {arc.to, -1.0}, {n_states + arc.label, 1.0}}, cost, ub}); } if (!opts.row_slack_terminators) { - for (const int q : graph.states) { - if (q >= loss_first) { columns.push_back(column_t{{{row_of(q), 1.0}}, 0.0, 1.0, -1}); } + const double ub = + opts.large_finite_capacities ? std::numeric_limits::max() : 1.0; + for (const state_t state : terminator_states) { + columns.push_back(column_t{{{state, 1.0}}, 0.0, ub}); } } + const int n_rows = n_states + n_labels; std::vector row_lb(n_rows, 0.0); std::vector row_ub(n_rows, 0.0); - row_lb[row_of(0)] = row_ub[row_of(0)] = n_machines; + row_lb[t0] = row_ub[t0] = n_paths; if (opts.row_slack_terminators) { - for (const int q : graph.states) { - if (q < loss_first) { continue; } - row_lb[row_of(q)] = -1.0; - row_ub[row_of(q)] = 0.0; + for (const state_t state : terminator_states) { + row_lb[state] = -1.0; + row_ub[state] = 0.0; } } - for (int j = 0; j < n_types; ++j) { - row_lb[n_states + j] = jobs[j].d; - row_ub[n_states + j] = std::numeric_limits::infinity(); - } - - if (opts.perturbed_cost_type >= 0) { - std::vector of_type; - for (int c = 0; c < (int)columns.size(); ++c) { - if (columns[c].type == opts.perturbed_cost_type) { of_type.push_back(c); } - } - EXPECT_GE(of_type.size(), 3u) << "an interior arc needs a type with at least three of them"; - columns[of_type[of_type.size() / 2]].cost += 1.0; + for (int label = 0; label < n_labels; ++label) { + row_lb[n_states + label] = label_demand[label]; + row_ub[n_states + label] = std::numeric_limits::infinity(); } const int n_cols = columns.size(); @@ -211,9 +127,6 @@ built_model_t build_eaf(const std::vector& jobs, const build_options } built_model_t model; - model.horizon = horizon; - model.job_arcs = graph.arcs.size(); - model.used_states = n_states; model.obj.assign(n_cols, 0.0); model.var_lb.assign(n_cols, 0.0); model.var_ub.assign(n_cols, 0.0); @@ -257,47 +170,9 @@ built_model_t build_eaf(const std::vector& jobs, const build_options return model; } -double brute_force_optimum(const std::vector& jobs) -{ - const int horizon = eaf_horizon(jobs); - const int loss_first = eaf_loss_first(jobs); - std::vector> expanded; - for (const auto& job : jobs) { - for (int i = 0; i < job.d; ++i) { - expanded.emplace_back(job.p, job.w); - } - } - const int n = expanded.size(); - double best = std::numeric_limits::infinity(); - for (int mask = 0; mask < (1 << n); ++mask) { - std::array>, n_machines> machine; - for (int i = 0; i < n; ++i) { - machine[(mask >> i) & 1].push_back(expanded[i]); - } - double cost = 0.0; - bool feasible = true; - for (auto& jobs_on_machine : machine) { - std::stable_sort(jobs_on_machine.begin(), - jobs_on_machine.end(), - [](const std::pair& a, const std::pair& b) { - return (long)a.second * b.first > (long)b.second * a.first; - }); - int clock = 0; - for (const auto& [p, w] : jobs_on_machine) { - cost += (double)w * clock; - clock += p; - } - if (clock < loss_first || clock > horizon) { feasible = false; } - } - if (feasible) { best = std::min(best, cost); } - } - return best; -} - struct run_outcome_t { bool prescreened{false}; bool found{false}; - bool exact{false}; double objective{0.0}; std::vector assignment; }; @@ -305,6 +180,8 @@ struct run_outcome_t { struct input_options_t { bool set_lower_bounds{true}; bool set_upper_bounds{true}; + bool maximize{false}; + bool use_internal_problem{false}; }; void expect_feasible(const built_model_t& model, const std::vector& assignment) @@ -348,17 +225,21 @@ run_outcome_t run_heuristic(const built_model_t& model, input_options_t options problem.set_variable_types(model.var_types.data(), model.var_types.size()); problem.set_constraint_lower_bounds(model.row_lb.data(), model.row_lb.size()); problem.set_constraint_upper_bounds(model.row_ub.data(), model.row_ub.size()); + problem.set_maximize(options.maximize); mip_solver_settings_t settings; run_outcome_t outcome; mip::arc_flow_t heuristic; - outcome.prescreened = heuristic.recognize(problem, settings.get_tolerances()); + if (options.use_internal_problem) { + mip::problem_t internal_problem(problem, settings.get_tolerances(), false); + outcome.prescreened = heuristic.recognize(internal_problem, settings.get_tolerances()); + } else { + outcome.prescreened = heuristic.recognize(problem, settings.get_tolerances()); + } if (!outcome.prescreened) { return outcome; } std::atomic preemption{false}; - const auto status = heuristic.solve(settings.get_tolerances(), preemption, outcome.assignment); - outcome.found = status == mip::structural_outcome_t::constructed; - outcome.exact = heuristic.search_was_exact(); + outcome.found = heuristic.solve(settings.get_tolerances(), preemption, outcome.assignment); if (outcome.found) { expect_feasible(model, outcome.assignment); outcome.objective = 0.0; @@ -369,107 +250,123 @@ run_outcome_t run_heuristic(const built_model_t& model, input_options_t options return outcome; } -const std::vector& small_instance() -{ - static const std::vector jobs = {{1, 3, 2}, {2, 1, 1}, {3, 2, 1}}; - return jobs; -} - -const std::vector& gapped_instance() -{ - static const std::vector jobs = {{2, 9, 2}, {5, 4, 2}, {7, 3, 1}}; - return jobs; -} - -const std::vector& single_arc_label_instance() -{ - static const std::vector jobs = {{2, 1, 1}, {5, 1, 1}, {6, 4, 1}}; - return jobs; -} - } // namespace -TEST(arc_flow, matches_brute_force_optimum) +TEST(arc_flow, finds_exact_optimum_on_reduced_graph) { - const auto model = build_eaf(small_instance(), {}); - const auto outcome = run_heuristic(model); + const auto outcome = run_heuristic(build_arc_flow()); ASSERT_TRUE(outcome.prescreened); ASSERT_TRUE(outcome.found); - EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); - EXPECT_TRUE(outcome.exact); + EXPECT_DOUBLE_EQ(outcome.objective, expected_objective); } -TEST(arc_flow, reduced_graph_omits_states_and_arcs) +TEST(arc_flow, handles_maximization_in_both_recognizers) { - const auto model = build_eaf(gapped_instance(), {}); - int straight = 0; - for (const auto& job : gapped_instance()) { - straight += std::max(0, eaf_horizon(gapped_instance()) - job.p + 1); + auto model = build_arc_flow(); + for (auto& coefficient : model.obj) { + coefficient = -coefficient; } - EXPECT_LT(model.job_arcs, straight) << "the reduction dropped no arc"; - EXPECT_LT(model.used_states, model.horizon + 1) << "the reduction dropped no state"; + const auto user_problem = run_heuristic(model, {.maximize = true}); + const auto internal_problem = + run_heuristic(model, {.maximize = true, .use_internal_problem = true}); + ASSERT_TRUE(user_problem.prescreened); + ASSERT_TRUE(user_problem.found); + ASSERT_TRUE(internal_problem.prescreened); + ASSERT_TRUE(internal_problem.found); + EXPECT_DOUBLE_EQ(user_problem.objective, -expected_objective); + EXPECT_DOUBLE_EQ(internal_problem.objective, -expected_objective); } -TEST(arc_flow, matches_brute_force_optimum_on_reduced_graph) +TEST(arc_flow, recognizes_large_scaled_demand_with_relative_tolerance) { - const auto outcome = run_heuristic(build_eaf(gapped_instance(), {})); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); - EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(gapped_instance())); -} + auto model = build_arc_flow(); + constexpr int cover_row = n_states; + constexpr int large_demand = 19998; + constexpr double row_scale = 0.1; + constexpr double normalized_offset = 1e-5; + const double scaled_demand = (large_demand + normalized_offset) * row_scale; + model.row_lb[cover_row] = scaled_demand; + for (int k = model.offsets[cover_row]; k < model.offsets[cover_row + 1]; ++k) { + model.values[k] = row_scale; + } + std::fill(model.var_ub.begin(), model.var_ub.end(), large_demand); -TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) -{ - const auto outcome = run_heuristic(build_eaf(single_arc_label_instance(), {})); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); - EXPECT_FALSE(outcome.exact); - EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(single_arc_label_instance())); -} + const double normalized_demand = scaled_demand / row_scale; + EXPECT_GT(std::abs(normalized_demand - std::round(normalized_demand)), 1e-9); -TEST(arc_flow, invariant_under_row_and_column_permutation) -{ - build_options_t permuted; - permuted.permute = true; - const auto plain = run_heuristic(build_eaf(small_instance(), {})); - const auto shuffled = run_heuristic(build_eaf(small_instance(), permuted)); - ASSERT_TRUE(plain.found); - ASSERT_TRUE(shuffled.found); - EXPECT_DOUBLE_EQ(plain.objective, shuffled.objective); + const raft::handle_t handle{}; + optimization_problem_t problem(&handle); + problem.set_csr_constraint_matrix(model.values.data(), + model.values.size(), + model.indices.data(), + model.indices.size(), + model.offsets.data(), + model.offsets.size()); + problem.set_objective_coefficients(model.obj.data(), model.obj.size()); + problem.set_variable_lower_bounds(model.var_lb.data(), model.var_lb.size()); + problem.set_variable_upper_bounds(model.var_ub.data(), model.var_ub.size()); + problem.set_variable_types(model.var_types.data(), model.var_types.size()); + problem.set_constraint_lower_bounds(model.row_lb.data(), model.row_lb.size()); + problem.set_constraint_upper_bounds(model.row_ub.data(), model.row_ub.size()); + + mip_solver_settings_t settings; + mip::arc_flow_t heuristic; + EXPECT_TRUE(heuristic.recognize(problem, settings.get_tolerances())); } -TEST(arc_flow, accepts_row_slack_terminators) +TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) { - build_options_t slack; - slack.row_slack_terminators = true; - const auto outcome = run_heuristic(build_eaf(small_instance(), slack)); + const auto outcome = run_heuristic(build_arc_flow({.single_arc_label = 1})); ASSERT_TRUE(outcome.prescreened); ASSERT_TRUE(outcome.found); - EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); + EXPECT_DOUBLE_EQ(outcome.objective, expected_objective); } -TEST(arc_flow, tolerates_row_scaling) +TEST(arc_flow, accepts_supported_variants) { - build_options_t scaled; - scaled.flow_row_factor = 4.0; - const auto outcome = run_heuristic(build_eaf(small_instance(), scaled)); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); - EXPECT_DOUBLE_EQ(outcome.objective, brute_force_optimum(small_instance())); + struct test_case_t { + const char* name; + build_options_t build_options; + input_options_t input_options; + double objective; + }; + const std::array cases{{ + {.name = "permutation", .build_options = {.permute = true}, .objective = expected_objective}, + {.name = "row scaling", + .build_options = {.flow_row_factor = 4.0}, + .objective = expected_objective}, + {.name = "row-slack termination", + .build_options = {.row_slack_terminators = true}, + .objective = expected_objective}, + {.name = "implicit zero lower bounds", + .input_options = {.set_lower_bounds = false}, + .objective = expected_objective}, + {.name = "large finite capacities", + .build_options = {.large_finite_capacities = true}, + .objective = expected_objective}, + {.name = "affine cost intercept", + .build_options = {.cost_intercept = 7.0}, + .objective = expected_objective + 7.0 * demanded_arcs}, + }}; + for (const auto& test_case : cases) { + SCOPED_TRACE(test_case.name); + const auto outcome = + run_heuristic(build_arc_flow(test_case.build_options), test_case.input_options); + EXPECT_TRUE(outcome.prescreened); + EXPECT_TRUE(outcome.found); + if (outcome.found) { EXPECT_DOUBLE_EQ(outcome.objective, test_case.objective); } + } } TEST(arc_flow, rejects_non_affine_costs) { - build_options_t perturbed; - perturbed.perturbed_cost_type = 1; - const auto outcome = run_heuristic(build_eaf(small_instance(), perturbed)); + const auto outcome = run_heuristic(build_arc_flow({.perturbed_cost_label = 0})); EXPECT_FALSE(outcome.found); } TEST(arc_flow, rejects_negative_cost_slope) { - const std::vector jobs = {{1, 3, 2}, {2, -1, 1}, {3, 2, 1}}; - const auto outcome = run_heuristic(build_eaf(jobs, {})); + const auto outcome = run_heuristic(build_arc_flow({.negative_cost_label = 2})); EXPECT_FALSE(outcome.found); } @@ -490,45 +387,16 @@ TEST(arc_flow, rejects_model_without_unit_incidence) EXPECT_FALSE(outcome.found); } -TEST(arc_flow, accepts_implicit_zero_lower_bounds) -{ - input_options_t options; - options.set_lower_bounds = false; - const auto outcome = run_heuristic(build_eaf(small_instance(), {}), options); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); -} - TEST(arc_flow, rejects_implicit_infinite_upper_bounds) { - input_options_t options; - options.set_upper_bounds = false; - const auto outcome = run_heuristic(build_eaf(small_instance(), {}), options); + const auto outcome = run_heuristic(build_arc_flow(), {.set_upper_bounds = false}); EXPECT_FALSE(outcome.prescreened); EXPECT_FALSE(outcome.found); } -TEST(arc_flow, accepts_large_finite_capacities) -{ - auto model = build_eaf(small_instance(), {}); - std::fill(model.var_ub.begin(), model.var_ub.end(), std::numeric_limits::max()); - const auto outcome = run_heuristic(model); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); -} - -TEST(arc_flow, accepts_affine_cost_intercept) -{ - build_options_t options; - options.cost_intercept = 7.0; - const auto outcome = run_heuristic(build_eaf(small_instance(), options)); - ASSERT_TRUE(outcome.prescreened); - ASSERT_TRUE(outcome.found); -} - TEST(arc_flow, is_reproducible) { - const auto model = build_eaf(small_instance(), {}); + const auto model = build_arc_flow(); const auto first = run_heuristic(model); const auto second = run_heuristic(model); ASSERT_TRUE(first.found); diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index 671d0243da..7592cadfc8 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -226,6 +226,8 @@ For pre-commit setup, DCO sign-off (`git commit -s`), the fork-based PR workflow ## Coding Conventions +Use `_Float128`, never `long double`, whenever extended precision arithmetic is required; `long double` is architecture-dependent and uses x87 on x86. + For C++ naming (`snake_case`, `d_`/`h_` prefixes, `_t` suffix), file extensions (`.hpp`/`.cpp`/`.cu`/`.cuh` and which compiler each uses), include order, Python style, error handling (`CUOPT_EXPECTS`, `RAFT_CUDA_TRY`), memory management (RMM patterns, no raw `new`/`delete`), CCCL bit/math helpers in device code, test-impact rules, volatile-comment rules (hardware names and self-referential issue/PR numbers in comments or skip messages go stale; issue links to a separate tracking issue are fine), **no large local lambdas** (extract named helpers instead), and **coarse work-estimate / time-limit gating** (phase/outer-loop only; no fine inner-loop or double checks), see [references/conventions.md](references/conventions.md). ## OpenMP task/runtime compatibility From 528557cfde77bf9d64e4391139d237ff80620626 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 31 Aug 2026 10:13:24 -0700 Subject: [PATCH 4/7] refactor and cleanup --- cpp/src/mip_heuristics/structural/arc_flow.cu | 412 +++++++----------- cpp/tests/mip/arc_flow_test.cu | 29 +- 2 files changed, 176 insertions(+), 265 deletions(-) diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index dd23155162..7f03a09afa 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -8,6 +8,7 @@ #include "arc_flow.cuh" #include +#include #include #include @@ -32,20 +33,6 @@ constexpr int arcflow_max_col_entries = 3; constexpr size_t arcflow_history_bytes_max = size_t{32} << 20; constexpr size_t arcflow_candidate_bytes_max = size_t{32} << 20; -// Structural inference reads objective coefficients and matrix entries at f_t precision, so the -// residual a genuine arc-flow model leaves behind is bounded by that precision and not by double. -struct arcflow_tol_t { - double abs{1e-9}; - double rel{1e-9}; -}; - -template -arcflow_tol_t structural_tolerance() -{ - const double eps = 8.0 * (double)(std::numeric_limits::epsilon()); - return arcflow_tol_t{std::max(1e-9, eps), std::max(1e-9, eps)}; -} - enum class row_role_t : uint8_t { flow, cover }; struct arc_t { @@ -59,7 +46,6 @@ struct arc_t { struct arc_flow_model_t { int n_nodes{0}; int n_labels{0}; - arcflow_tol_t tol; std::vector phi; std::vector path_start; @@ -95,16 +81,6 @@ struct arc_flow_result_t { bool exact{true}; }; -bool close_to(double a, double b, double scale, const arcflow_tol_t& tol) -{ - return std::abs(a - b) <= tol.abs + tol.rel * scale; -} - -bool is_integral(double v, const arcflow_tol_t& tol) -{ - return std::abs(v - std::round(v)) <= tol.abs + tol.rel * std::abs(v); -} - bool is_known(double v) { return !std::isnan(v); } struct arcflow_profile_t { @@ -124,10 +100,9 @@ template struct host_problem_t { i_t n_variables{0}; i_t n_constraints{0}; - arcflow_tol_t tol; - std::vector csc_values; - std::vector csc_rows; - std::vector csc_offsets; + std::vector csr_values; + std::vector csr_cols; + std::vector csr_offsets; std::vector row_lb; std::vector row_ub; std::vector obj; @@ -163,99 +138,43 @@ bool arcflow_accepts_bounds(const std::vector& row_lb, const std::vector 0 && n_cover_candidates > 0; } -template -arcflow_profile_t profile_from_csr(i_t n_variables, - i_t n_constraints, - const std::vector& csr_values, - const std::vector& csr_cols, - const std::vector& csr_offsets) +// The row loop needs every magnitude in the row, so the acceptance pass cannot merge into the +// nonzero pass that builds the profile. +template +bool arcflow_accepts_profile(const host_problem_t& h, + const tolerance_t& tolerances, + arcflow_profile_t& p) { - arcflow_profile_t p(n_variables, n_constraints); - for (i_t r = 0; r < n_constraints; ++r) { - for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { - const i_t col = csr_cols[k]; - cuopt_assert(col >= 0 && col < n_variables, "Column index out of range"); - const double mag = std::abs((double)csr_values[k]); - ++p.col_entries[col]; + p = arcflow_profile_t(h.n_variables, h.n_constraints); + for (i_t r = 0; r < h.n_constraints; ++r) { + for (i_t k = h.csr_offsets[r]; k < h.csr_offsets[r + 1]; ++k) { + const i_t col = h.csr_cols[k]; + cuopt_assert(col >= 0 && col < h.n_variables, "Column index out of range"); + if (++p.col_entries[col] > arcflow_max_col_entries) { return false; } + const double mag = std::abs((double)h.csr_values[k]); p.row_min_mag[r] = std::min(p.row_min_mag[r], mag); p.row_max_mag[r] = std::max(p.row_max_mag[r], mag); } } - return p; -} - -template -arcflow_profile_t profile_from_csc(i_t n_variables, - i_t n_constraints, - const std::vector& csc_values, - const std::vector& csc_rows, - const std::vector& csc_offsets) -{ - arcflow_profile_t p(n_variables, n_constraints); - for (i_t j = 0; j < n_variables; ++j) { - p.col_entries[j] = csc_offsets[j + 1] - csc_offsets[j]; - for (i_t k = csc_offsets[j]; k < csc_offsets[j + 1]; ++k) { - const i_t row = csc_rows[k]; - cuopt_assert(row >= 0 && row < n_constraints, "Row index out of range"); - const double mag = std::abs((double)csc_values[k]); - p.row_min_mag[row] = std::min(p.row_min_mag[row], mag); - p.row_max_mag[row] = std::max(p.row_max_mag[row], mag); - } - } - return p; -} - -template -bool arcflow_accepts_profile(const arcflow_profile_t& p, - const std::vector& row_lb, - const std::vector& row_ub, - const arcflow_tol_t& tol) -{ - for (const int64_t entries : p.col_entries) { - if (entries > arcflow_max_col_entries) { return false; } - } double cover_demand = 0.0; - for (size_t r = 0; r < p.row_max_mag.size(); ++r) { - if (p.row_max_mag[r] == 0.0 || p.row_min_mag[r] <= tol.abs) { return false; } - if (!close_to(p.row_min_mag[r], p.row_max_mag[r], p.row_max_mag[r], tol)) { return false; } - if (!std::isfinite((double)row_ub[r])) { - const double demand = row_lb[r] / p.row_max_mag[r]; - if (!is_integral(demand, tol) || demand < 1.0 - tol.abs) { return false; } + for (i_t r = 0; r < h.n_constraints; ++r) { + if (p.row_max_mag[r] == 0.0 || p.row_min_mag[r] <= tolerances.absolute_tolerance) { + return false; + } + if (p.row_max_mag[r] - p.row_min_mag[r] > tolerances.absolute_tolerance) { return false; } + if (!std::isfinite((double)h.row_ub[r])) { + const double demand = h.row_lb[r] / p.row_max_mag[r]; + if (!is_integer(demand, tolerances.integrality_tolerance) || + demand < 1.0 - tolerances.absolute_tolerance) { + return false; + } cover_demand += std::round(demand); } } return cover_demand > 0.0 && cover_demand <= arcflow_max_tokens; } -// The model arrives row major and the detector reads it column major, so the transpose is built -// here by counting sort. Rows stay ascending within a column, which build_structure relies on. -template -void transpose_into_csc(const std::vector& csr_values, - const std::vector& csr_cols, - const std::vector& csr_offsets, - host_problem_t& h) -{ - const size_t nnz = csr_values.size(); - h.csc_offsets.assign((size_t)h.n_variables + 1, 0); - for (size_t k = 0; k < nnz; ++k) { - ++h.csc_offsets[(size_t)csr_cols[k] + 1]; - } - for (i_t j = 0; j < h.n_variables; ++j) { - h.csc_offsets[j + 1] += h.csc_offsets[j]; - } - h.csc_rows.assign(nnz, 0); - h.csc_values.assign(nnz, f_t{0}); - std::vector cursor(h.csc_offsets.begin(), h.csc_offsets.end() - 1); - for (i_t r = 0; r < h.n_constraints; ++r) { - for (i_t k = csr_offsets[r]; k < csr_offsets[r + 1]; ++k) { - const i_t slot = cursor[csr_cols[k]]++; - h.csc_rows[slot] = r; - h.csc_values[slot] = csr_values[k]; - } - } -} - struct row_info_t { row_role_t role{row_role_t::cover}; double scale{1.0}; @@ -263,9 +182,10 @@ struct row_info_t { double hi{0.0}; }; -template +template bool classify_rows(const host_problem_t& h, const arcflow_profile_t& profile, + const tolerance_t& tolerances, std::vector& rows) { cuopt_assert((i_t)profile.row_min_mag.size() == h.n_constraints, "Size mismatch"); @@ -285,7 +205,10 @@ bool classify_rows(const host_problem_t& h, info.lo = lo; info.hi = hi; } else if (lo_fin) { - if (!is_integral(lo, h.tol) || lo < 1.0 - h.tol.abs) { return false; } + if (!is_integer(lo, tolerances.integrality_tolerance) || + lo < 1.0 - tolerances.absolute_tolerance) { + return false; + } info.role = row_role_t::cover; info.lo = std::round(lo); info.hi = hi; @@ -299,42 +222,53 @@ bool classify_rows(const host_problem_t& h, // The nonzero flow right-hand sides must all carry one sign. That sign says which incidence of an // arc is its tail, and their absolute sum is the number of paths. Returns 0 when ambiguous. +template double supply_orientation(const std::vector& rows, - const arcflow_tol_t& tol, + const tolerance_t& tolerances, double& total) { + const double absolute_tolerance = tolerances.absolute_tolerance; double positive = 0.0; double negative = 0.0; for (const auto& info : rows) { if (info.role != row_role_t::flow) { continue; } - if (info.lo > tol.abs) { positive += info.lo; } - if (info.hi < -tol.abs) { negative += -info.hi; } + if (info.lo > absolute_tolerance) { positive += info.lo; } + if (info.hi < -absolute_tolerance) { negative += -info.hi; } } - if (positive > tol.abs && negative > tol.abs) { return 0.0; } - if (positive > tol.abs) { + if (positive > absolute_tolerance && negative > absolute_tolerance) { return 0.0; } + if (positive > absolute_tolerance) { total = positive; return 1.0; } - if (negative > tol.abs) { + if (negative > absolute_tolerance) { total = negative; return -1.0; } return 0.0; } +// One incidence per role at most, so a fourth column entry necessarily duplicates one of the three +// and is rejected without counting entries. +struct column_incidence_t { + int tail{-1}; + int head{-1}; + int label{-1}; +}; + // A column carrying a single flow incidence is accepted only when that incidence is the arc's // tail: sources are read from the right-hand side, so the mirror encoding of an explicit // injection arc is out of scope here. -template +template bool build_structure(const host_problem_t& h, std::vector& rows, + const tolerance_t& tolerances, arc_flow_model_t& model) { - model.tol = h.tol; - double supply_total = 0.0; - const double sign = supply_orientation(rows, h.tol, supply_total); - if (sign == 0.0 || !is_integral(supply_total, h.tol)) { return false; } + const double sign = supply_orientation(rows, tolerances, supply_total); + if (sign == 0.0 || !is_integer(supply_total, tolerances.integrality_tolerance)) { + return false; + } if (std::round(supply_total) != arcflow_paths_supported) { return false; } // Reorient so a normalized coefficient of +1 always means "the arc leaves this node". @@ -377,14 +311,15 @@ bool build_structure(const host_problem_t& h, const int v = node_of_row[r]; if (v < 0) { continue; } const auto& info = rows[r]; - if (info.lo > h.tol.abs) { - if (!close_to(info.lo, info.hi, std::abs(info.lo), h.tol) || !is_integral(info.lo, h.tol)) { + if (info.lo > tolerances.absolute_tolerance) { + if (std::abs(info.lo - info.hi) > tolerances.absolute_tolerance || + !is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } model.path_start.insert(model.path_start.end(), (size_t)std::round(info.lo), v); - } else if (std::abs(info.hi) <= h.tol.abs) { - if (info.lo < -h.tol.abs) { - if (!is_integral(info.lo, h.tol)) { return false; } + } else if (std::abs(info.hi) <= tolerances.absolute_tolerance) { + if (info.lo < -tolerances.absolute_tolerance) { + if (!is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } model.terminator_capacity[v] = std::min((double)arcflow_paths_supported, std::round(-info.lo)); } @@ -394,47 +329,48 @@ bool build_structure(const host_problem_t& h, } if ((int)model.path_start.size() != arcflow_paths_supported) { return false; } - for (int j = 0; j < h.n_variables; ++j) { - if (h.var_types[j] != var_t::INTEGER) { return false; } - if (!std::isfinite((double)h.obj[j])) { return false; } - if (std::abs((double)h.var_lb[j]) > h.tol.abs) { return false; } - const double ub = h.var_ub[j]; - if (!std::isfinite(ub) || ub < 1.0 - h.tol.abs) { return false; } - - const int begin = h.csc_offsets[j]; - const int end = h.csc_offsets[j + 1]; - if (end == begin || end - begin > arcflow_max_col_entries) { return false; } - - int tail = -1; - int head = -1; - int label = -1; - for (int k = begin; k < end; ++k) { - const int r = h.csc_rows[k]; - const double unit = h.csc_values[k] / rows[r].scale; + std::vector columns(h.n_variables); + for (int r = 0; r < h.n_constraints; ++r) { + for (i_t k = h.csr_offsets[r]; k < h.csr_offsets[r + 1]; ++k) { + const i_t j = h.csr_cols[k]; + cuopt_assert(j >= 0 && j < h.n_variables, "Column index out of range"); + auto& column = columns[j]; + const double unit = h.csr_values[k] / rows[r].scale; if (rows[r].role == row_role_t::flow) { const double oriented = unit * sign; - if (close_to(oriented, 1.0, 1.0, h.tol)) { - if (tail >= 0) { return false; } - tail = node_of_row[r]; - } else if (close_to(oriented, -1.0, 1.0, h.tol)) { - if (head >= 0) { return false; } - head = node_of_row[r]; + if (std::abs(oriented - 1.0) <= tolerances.absolute_tolerance) { + if (column.tail >= 0) { return false; } + column.tail = node_of_row[r]; + } else if (std::abs(oriented + 1.0) <= tolerances.absolute_tolerance) { + if (column.head >= 0) { return false; } + column.head = node_of_row[r]; } else { return false; } } else { // A covering incidence is positive irrespective of the flow orientation. - if (!close_to(unit, 1.0, 1.0, h.tol)) { return false; } - if (label >= 0) { return false; } - label = label_of_row[r]; + if (std::abs(unit - 1.0) > tolerances.absolute_tolerance) { return false; } + if (column.label >= 0) { return false; } + column.label = label_of_row[r]; } } + } + for (int j = 0; j < h.n_variables; ++j) { + if (h.var_types[j] != var_t::INTEGER) { return false; } + if (!std::isfinite((double)h.obj[j])) { return false; } + if (std::abs((double)h.var_lb[j]) > tolerances.absolute_tolerance) { return false; } + const double ub = h.var_ub[j]; + if (!std::isfinite(ub) || ub < 1.0 - tolerances.absolute_tolerance) { return false; } + + const int tail = columns[j].tail; + const int head = columns[j].head; + const int label = columns[j].label; if (tail >= 0 && head >= 0) { if (label < 0) { return false; } // With this bound the arc capacity can never bind: a use of the arc consumes one of the // label's tokens, and there are exactly demand[label] of them. - if ((double)model.demand[label] > ub + h.tol.abs) { return false; } + if ((double)model.demand[label] > ub + tolerances.absolute_tolerance) { return false; } model.arcs.push_back(arc_t{tail, head, label, j, (double)h.obj[j]}); } else if (tail >= 0 && label < 0) { // Explicit loss arc: it leaves the node and never arrives, so it ends a path. A node that @@ -444,7 +380,8 @@ bool build_structure(const host_problem_t& h, model.terminator_col[tail] = j; model.terminator_cost[tail] = h.obj[j]; model.terminator_capacity[tail] = - std::min((double)arcflow_paths_supported, std::floor(ub + h.tol.abs)); + std::min((double)arcflow_paths_supported, + std::floor(ub + tolerances.absolute_tolerance)); } else { return false; } @@ -475,10 +412,12 @@ bool build_structure(const host_problem_t& h, // the potential on every node it touches, and the remaining labels are fitted and extended from // there. The result is an affine image of the true potential, which leaves the order that // consumes it unchanged, since a common positive factor cancels out of every comparison. -bool derive_potential(arc_flow_model_t& model, const std::atomic& preemption_flag) +template +bool derive_potential(arc_flow_model_t& model, + const tolerance_t& tolerances, + const std::atomic& preemption_flag) { - const int n_labels = model.n_labels; - const arcflow_tol_t tol = model.tol; + const int n_labels = model.n_labels; int reference = -1; size_t best_count = 0; @@ -529,7 +468,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti if (lowest >= 0 && highest >= 0) { const double lo_phi = model.phi[model.arcs[lowest].from]; const double hi_phi = model.phi[model.arcs[highest].from]; - if (!close_to(lo_phi, hi_phi, std::abs(hi_phi), tol)) { + if (std::abs(lo_phi - hi_phi) > tolerances.absolute_tolerance) { model.slope[l] = (model.arcs[highest].cost - model.arcs[lowest].cost) / (hi_phi - lo_phi); intercept[l] = model.arcs[lowest].cost - model.slope[l] * lo_phi; @@ -537,7 +476,8 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti } } } - if (is_known(model.slope[l]) && std::abs(model.slope[l]) > tol.abs) { + if (is_known(model.slope[l]) && + std::abs(model.slope[l]) > tolerances.absolute_tolerance) { for (int k = begin; k < end; ++k) { const int from = model.arcs[k].from; if (is_known(model.phi[from])) { continue; } @@ -586,7 +526,7 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti for (double p : model.phi) { phi_scale = std::max(phi_scale, std::abs(p)); } - if (phi_scale <= tol.abs) { return false; } + if (phi_scale <= tolerances.absolute_tolerance) { return false; } // Orient the potential so displacements are positive. A consistent potential whose // displacements are all strictly positive is exactly what makes the arc graph acyclic: a cycle @@ -607,35 +547,32 @@ bool derive_potential(arc_flow_model_t& model, const std::atomic& preempti } } for (double p : model.displacement) { - if (p <= tol.abs) { return false; } + if (p <= tolerances.absolute_tolerance) { return false; } } // Smith ordering assumes nonnegative job weights. for (double w : model.slope) { - if (is_known(w) && w < -tol.abs) { return false; } + if (is_known(w) && w < -tolerances.absolute_tolerance) { return false; } } // Verify against every arc, not just the two points each fit was built from. The cost residual // matters as much as the potential: the order is read off the fitted slopes, so a label whose // costs are not affine in the potential would be ordered on a meaningless quantity. - double cost_scale = 0.0; - for (const auto& arc : model.arcs) { - cost_scale = std::max(cost_scale, std::abs(arc.cost)); - } for (const auto& arc : model.arcs) { - if (!close_to( - model.phi[arc.to], model.phi[arc.from] + model.displacement[arc.label], phi_scale, tol)) { - return false; - } + const double displaced = model.phi[arc.from] + model.displacement[arc.label]; + if (std::abs(model.phi[arc.to] - displaced) > tolerances.absolute_tolerance) { return false; } if (!is_known(model.slope[arc.label])) { continue; } const double predicted = model.slope[arc.label] * model.phi[arc.from] + intercept[arc.label]; - if (!close_to(predicted, arc.cost, cost_scale, tol)) { return false; } + if (std::abs(predicted - arc.cost) > tolerances.absolute_tolerance) { return false; } } return true; } // Weighted shortest processing time orders labels by decreasing slope over displacement. -std::vector token_order(const arc_flow_model_t& model, bool& all_slopes_identified) +template +std::vector token_order(const arc_flow_model_t& model, + const tolerance_t& tolerances, + bool& all_slopes_identified) { std::vector lowest_phi(model.n_labels, 0.0); for (int l = 0; l < model.n_labels; ++l) { @@ -657,30 +594,24 @@ std::vector token_order(const arc_flow_model_t& model, bool& all_slopes_ide } } - const arcflow_tol_t tol = model.tol; - const auto cross = [&](int a, int b) { - return (_Float128)model.slope[a] * (_Float128)model.displacement[b]; - }; - // Approximate equality is not transitive. Tolerance forms ratio classes before the final sort. std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { - const _Float128 lhs = cross(a, b); - const _Float128 rhs = cross(b, a); + const _Float128 lhs = (_Float128)model.slope[a] * (_Float128)model.displacement[b]; + const _Float128 rhs = (_Float128)model.slope[b] * (_Float128)model.displacement[a]; if (lhs != rhs) { return lhs > rhs; } return a < b; }); std::vector ratio_class(model.n_labels, 0); for (size_t position = 1; position < ordered.size(); ++position) { - const int previous = ordered[position - 1]; - const int current = ordered[position]; - const _Float128 lhs = cross(previous, current); - const _Float128 rhs = cross(current, previous); - const _Float128 lhs_magnitude = lhs < 0 ? -lhs : lhs; - const _Float128 rhs_magnitude = rhs < 0 ? -rhs : rhs; - const _Float128 scale = std::max(lhs_magnitude, rhs_magnitude); - const _Float128 difference = lhs > rhs ? lhs - rhs : rhs - lhs; - const bool tied = difference <= (_Float128)tol.abs + (_Float128)tol.rel * scale; - ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); + const int previous = ordered[position - 1]; + const int current = ordered[position]; + const _Float128 lhs = + (_Float128)model.slope[previous] * (_Float128)model.displacement[current]; + const _Float128 rhs = + (_Float128)model.slope[current] * (_Float128)model.displacement[previous]; + const _Float128 difference = lhs > rhs ? lhs - rhs : rhs - lhs; + const bool tied = difference <= (_Float128)tolerances.absolute_tolerance; + ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); } std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { if (ratio_class[a] != ratio_class[b]) { return ratio_class[a] < ratio_class[b]; } @@ -719,13 +650,6 @@ std::vector token_order(const arc_flow_model_t& model, bool& all_slopes_ide return tokens; } -bool state_less(const frontier_t& a, const frontier_t& b) -{ - return std::lexicographical_compare(a.node.begin(), a.node.end(), b.node.begin(), b.node.end()); -} - -bool state_equal(const frontier_t& a, const frontier_t& b) { return a.node == b.node; } - std::optional run_dp(const arc_flow_model_t& model, const std::vector& tokens, const std::atomic& preemption_flag) @@ -801,7 +725,7 @@ std::optional run_dp(const arc_flow_model_t& model, // A total order makes representative selection independent of enumeration order. std::sort(candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - if (!state_equal(a.front, b.front)) { return state_less(a.front, b.front); } + if (a.front.node != b.front.node) { return a.front.node < b.front.node; } if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } if (a.parent.prev != b.parent.prev) { return a.parent.prev < b.parent.prev; } return a.parent.arc < b.parent.arc; @@ -809,7 +733,7 @@ std::optional run_dp(const arc_flow_model_t& model, candidates.erase(std::unique(candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - return state_equal(a.front, b.front); + return a.front.node == b.front.node; }), candidates.end()); @@ -820,12 +744,12 @@ std::optional run_dp(const arc_flow_model_t& model, std::stable_sort( candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } - return state_less(a.front, b.front); + return a.front.node < b.front.node; }); candidates.resize(affordable); std::sort( candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - return state_less(a.front, b.front); + return a.front.node < b.front.node; }); result.exact = false; } @@ -904,65 +828,54 @@ arc_flow_t::~arc_flow_t() = default; template bool arc_flow_t::recognize(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t&) + const typename mip_solver_settings_t::tolerances_t& + tolerances) { const i_t n_variables = op_problem.get_n_variables(); const i_t n_constraints = op_problem.get_n_constraints(); if (!arcflow_accepts_shape(n_variables, n_constraints, op_problem.get_nnz())) { return false; } if (op_problem.get_n_integers() != n_variables) { return false; } - - auto stream = op_problem.get_handle_ptr()->get_stream(); - const auto& d_row_lb = op_problem.get_constraint_lower_bounds(); - const auto& d_row_ub = op_problem.get_constraint_upper_bounds(); - const auto& d_values = op_problem.get_constraint_matrix_values(); - const auto& d_indices = op_problem.get_constraint_matrix_indices(); - const auto& d_offsets = op_problem.get_constraint_matrix_offsets(); - const auto& d_obj = op_problem.get_objective_coefficients(); - const auto& d_var_lb = op_problem.get_variable_lower_bounds(); - const auto& d_var_ub = op_problem.get_variable_upper_bounds(); - const auto& d_var_types = op_problem.get_variable_types(); - if ((i_t)d_row_lb.size() != n_constraints || (i_t)d_row_ub.size() != n_constraints) { + if (!op_problem.get_variable_lower_bounds().is_empty() && + (i_t)op_problem.get_variable_lower_bounds().size() != n_variables) { return false; } - if ((i_t)d_offsets.size() != n_constraints + 1) { return false; } - if (d_values.size() != d_indices.size()) { return false; } - cuopt_assert((i_t)d_obj.size() == n_variables, "Size mismatch"); - cuopt_assert((i_t)d_var_types.size() == n_variables, "Size mismatch"); - if (!d_var_lb.is_empty() && (i_t)d_var_lb.size() != n_variables) { return false; } - if ((i_t)d_var_ub.size() != n_variables) { return false; } + if ((i_t)op_problem.get_variable_upper_bounds().size() != n_variables) { return false; } + + auto stream = op_problem.get_handle_ptr()->get_stream(); host_problem_t h; h.n_variables = n_variables; h.n_constraints = n_constraints; - h.tol = structural_tolerance(); - h.row_lb = cuopt::host_copy(d_row_lb, stream); - h.row_ub = cuopt::host_copy(d_row_ub, stream); + h.row_lb = cuopt::host_copy(op_problem.get_constraint_lower_bounds(), stream); + h.row_ub = cuopt::host_copy(op_problem.get_constraint_upper_bounds(), stream); if (!arcflow_accepts_bounds(h.row_lb, h.row_ub)) { return false; } - const auto values = cuopt::host_copy(d_values, stream); - const auto indices = cuopt::host_copy(d_indices, stream); - const auto offsets = cuopt::host_copy(d_offsets, stream); - auto profile = profile_from_csr(n_variables, n_constraints, values, indices, offsets); - if (!arcflow_accepts_profile(profile, h.row_lb, h.row_ub, h.tol)) { return false; } + h.csr_values = cuopt::host_copy(op_problem.get_constraint_matrix_values(), stream); + h.csr_cols = cuopt::host_copy(op_problem.get_constraint_matrix_indices(), stream); + h.csr_offsets = cuopt::host_copy(op_problem.get_constraint_matrix_offsets(), stream); + arcflow_profile_t profile; + if (!arcflow_accepts_profile(h, tolerances, profile)) { return false; } - h.obj = cuopt::host_copy(d_obj, stream); + h.obj = cuopt::host_copy(op_problem.get_objective_coefficients(), stream); if (op_problem.get_sense()) { for (auto& coefficient : h.obj) { coefficient = -coefficient; } } h.var_lb.assign(n_variables, f_t{0}); - if (!d_var_lb.is_empty()) { h.var_lb = cuopt::host_copy(d_var_lb, stream); } - h.var_ub = cuopt::host_copy(d_var_ub, stream); - h.var_types = cuopt::host_copy(d_var_types, stream); - transpose_into_csc(values, indices, offsets, h); - state_ = std::make_unique(std::move(h), std::move(profile)); + if (!op_problem.get_variable_lower_bounds().is_empty()) { + h.var_lb = cuopt::host_copy(op_problem.get_variable_lower_bounds(), stream); + } + h.var_ub = cuopt::host_copy(op_problem.get_variable_upper_bounds(), stream); + h.var_types = cuopt::host_copy(op_problem.get_variable_types(), stream); + state_ = std::make_unique(std::move(h), std::move(profile)); return true; } template bool arc_flow_t::recognize(const problem_t& problem, - const typename mip_solver_settings_t::tolerances_t&) + const typename mip_solver_settings_t::tolerances_t& + tolerances) { const i_t n_variables = problem.n_variables; const i_t n_constraints = problem.n_constraints; @@ -970,28 +883,19 @@ bool arc_flow_t::recognize(const problem_t& problem, if (problem.n_integer_vars != n_variables) { return false; } auto stream = problem.handle_ptr->get_stream(); - cuopt_assert((i_t)problem.constraint_lower_bounds.size() == n_constraints, "Size mismatch"); - cuopt_assert((i_t)problem.constraint_upper_bounds.size() == n_constraints, "Size mismatch"); - cuopt_assert((i_t)problem.reverse_offsets.size() == n_variables + 1, "Size mismatch"); - cuopt_assert(problem.reverse_coefficients.size() == problem.reverse_constraints.size(), - "Size mismatch"); - cuopt_assert((i_t)problem.objective_coefficients.size() == n_variables, "Size mismatch"); - cuopt_assert((i_t)problem.variable_types.size() == n_variables, "Size mismatch"); host_problem_t h; h.n_variables = n_variables; h.n_constraints = n_constraints; - h.tol = structural_tolerance(); - h.csc_values = cuopt::host_copy(problem.reverse_coefficients, stream); - h.csc_rows = cuopt::host_copy(problem.reverse_constraints, stream); - h.csc_offsets = cuopt::host_copy(problem.reverse_offsets, stream); + h.csr_values = cuopt::host_copy(problem.coefficients, stream); + h.csr_cols = cuopt::host_copy(problem.variables, stream); + h.csr_offsets = cuopt::host_copy(problem.offsets, stream); h.row_lb = cuopt::host_copy(problem.constraint_lower_bounds, stream); h.row_ub = cuopt::host_copy(problem.constraint_upper_bounds, stream); if (!arcflow_accepts_bounds(h.row_lb, h.row_ub)) { return false; } - auto profile = - profile_from_csc(n_variables, n_constraints, h.csc_values, h.csc_rows, h.csc_offsets); - if (!arcflow_accepts_profile(profile, h.row_lb, h.row_ub, h.tol)) { return false; } + arcflow_profile_t profile; + if (!arcflow_accepts_profile(h, tolerances, profile)) { return false; } h.obj = cuopt::host_copy(problem.objective_coefficients, stream); h.var_types = cuopt::host_copy(problem.variable_types, stream); @@ -1003,7 +907,7 @@ bool arc_flow_t::recognize(const problem_t& problem, template bool arc_flow_t::solve( - const typename mip_solver_settings_t::tolerances_t&, + const typename mip_solver_settings_t::tolerances_t& tolerances, std::atomic& preemption, std::vector& assignment) { @@ -1011,26 +915,26 @@ bool arc_flow_t::solve( const auto& h = state_->h; std::vector rows; - if (!classify_rows(h, state_->profile, rows)) { + if (!classify_rows(h, state_->profile, tolerances, rows)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: rows are not unit incidence after normalization"); return false; } arc_flow_model_t model; - if (!build_structure(h, rows, model)) { + if (!build_structure(h, rows, tolerances, model)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: columns do not match the labelled arc pattern"); return false; } if (preemption.load()) { return false; } - if (!derive_potential(model, preemption)) { + if (!derive_potential(model, tolerances, preemption)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: no consistent potential and affine cost model"); return false; } if (preemption.load()) { return false; } bool all_slopes_identified = true; - const auto tokens = token_order(model, all_slopes_identified); + const auto tokens = token_order(model, tolerances, all_slopes_identified); CUOPT_LOG_DEBUG("[ArcFlow] detected %d nodes, %d labels, %d paths, %zu tokens, ordering %s", model.n_nodes, model.n_labels, diff --git a/cpp/tests/mip/arc_flow_test.cu b/cpp/tests/mip/arc_flow_test.cu index e7ed1fbce6..11293d414e 100644 --- a/cpp/tests/mip/arc_flow_test.cu +++ b/cpp/tests/mip/arc_flow_test.cu @@ -277,22 +277,30 @@ TEST(arc_flow, handles_maximization_in_both_recognizers) EXPECT_DOUBLE_EQ(internal_problem.objective, -expected_objective); } -TEST(arc_flow, recognizes_large_scaled_demand_with_relative_tolerance) +TEST(arc_flow, uses_solver_integrality_tolerance_for_scaled_demand) { - auto model = build_arc_flow(); - constexpr int cover_row = n_states; - constexpr int large_demand = 19998; - constexpr double row_scale = 0.1; - constexpr double normalized_offset = 1e-5; - const double scaled_demand = (large_demand + normalized_offset) * row_scale; - model.row_lb[cover_row] = scaled_demand; + constexpr double test_integrality_tolerance = 1e-4; + mip_solver_settings_t settings; + auto tolerances = settings.get_tolerances(); + tolerances.integrality_tolerance = test_integrality_tolerance; + const double normalized_offset = tolerances.integrality_tolerance / 2.0; + + auto model = build_arc_flow(); + constexpr int cover_row = n_states; + constexpr int large_demand = 19998; + constexpr double row_scale = 0.1; + const double scaled_demand = (large_demand + normalized_offset) * row_scale; + model.row_lb[cover_row] = scaled_demand; for (int k = model.offsets[cover_row]; k < model.offsets[cover_row + 1]; ++k) { model.values[k] = row_scale; } std::fill(model.var_ub.begin(), model.var_ub.end(), large_demand); const double normalized_demand = scaled_demand / row_scale; - EXPECT_GT(std::abs(normalized_demand - std::round(normalized_demand)), 1e-9); + const double integrality_error = + std::abs(normalized_demand - std::round(normalized_demand)); + EXPECT_GT(integrality_error, tolerances.absolute_tolerance); + EXPECT_LE(integrality_error, tolerances.integrality_tolerance); const raft::handle_t handle{}; optimization_problem_t problem(&handle); @@ -309,9 +317,8 @@ TEST(arc_flow, recognizes_large_scaled_demand_with_relative_tolerance) problem.set_constraint_lower_bounds(model.row_lb.data(), model.row_lb.size()); problem.set_constraint_upper_bounds(model.row_ub.data(), model.row_ub.size()); - mip_solver_settings_t settings; mip::arc_flow_t heuristic; - EXPECT_TRUE(heuristic.recognize(problem, settings.get_tolerances())); + EXPECT_TRUE(heuristic.recognize(problem, tolerances)); } TEST(arc_flow, single_arc_label_is_ordered_but_not_exact) From f16c621fab45434a5842aa05918047f6aae80102 Mon Sep 17 00:00:00 2001 From: yboucher Date: Mon, 31 Aug 2026 10:18:36 -0700 Subject: [PATCH 5/7] style --- cpp/src/mip_heuristics/solver.cu | 10 +-- cpp/src/mip_heuristics/structural/arc_flow.cu | 67 ++++++++----------- .../mip_heuristics/structural/arc_flow.cuh | 7 +- .../structural/early_structural.cu | 7 +- .../structural/early_structural.cuh | 7 +- cpp/tests/mip/arc_flow_test.cu | 29 ++++---- 6 files changed, 56 insertions(+), 71 deletions(-) diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index 5214f08b73..22b4672496 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -231,9 +231,10 @@ solution_t mip_solver_t::run_solver() if (context.early_structural_ptr) { context.early_structural_ptr->stop(); if (context.early_structural_ptr->solution_found()) { - CUOPT_LOG_DEBUG("Early structural heuristic found incumbent with user-space objective %g " - "during presolve", - context.early_structural_ptr->get_best_user_objective()); + CUOPT_LOG_DEBUG( + "Early structural heuristic found incumbent with user-space objective %g " + "during presolve", + context.early_structural_ptr->get_best_user_objective()); } } @@ -499,8 +500,7 @@ solution_t mip_solver_t::run_solver() context.settings.get_tolerances(), context.preempt_heuristic_solver_, [&dm](const std::vector& assignment, f_t objective) { - dm.population.add_external_solution( - assignment, objective, solution_origin_t::EXTERNAL); + dm.population.add_external_solution(assignment, objective, solution_origin_t::EXTERNAL); }); if (!root_structural->recognized()) { root_structural.reset(); } } diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index 7f03a09afa..3cbcb16fcd 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -27,10 +27,10 @@ namespace cuopt::mathematical_optimization::mip { namespace { -constexpr int arcflow_paths_supported = 2; -constexpr int arcflow_max_tokens = 20000; -constexpr int arcflow_max_col_entries = 3; -constexpr size_t arcflow_history_bytes_max = size_t{32} << 20; +constexpr int arcflow_paths_supported = 2; +constexpr int arcflow_max_tokens = 20000; +constexpr int arcflow_max_col_entries = 3; +constexpr size_t arcflow_history_bytes_max = size_t{32} << 20; constexpr size_t arcflow_candidate_bytes_max = size_t{32} << 20; enum class row_role_t : uint8_t { flow, cover }; @@ -228,8 +228,8 @@ double supply_orientation(const std::vector& rows, double& total) { const double absolute_tolerance = tolerances.absolute_tolerance; - double positive = 0.0; - double negative = 0.0; + double positive = 0.0; + double negative = 0.0; for (const auto& info : rows) { if (info.role != row_role_t::flow) { continue; } if (info.lo > absolute_tolerance) { positive += info.lo; } @@ -377,11 +377,10 @@ bool build_structure(const host_problem_t& h, // already absorbs paths through row slack would need the two capacities apportioned, which // no model in this family does, so reject rather than guess. if (model.terminator_col[tail] >= 0 || model.terminator_capacity[tail] > 0) { return false; } - model.terminator_col[tail] = j; - model.terminator_cost[tail] = h.obj[j]; + model.terminator_col[tail] = j; + model.terminator_cost[tail] = h.obj[j]; model.terminator_capacity[tail] = - std::min((double)arcflow_paths_supported, - std::floor(ub + tolerances.absolute_tolerance)); + std::min((double)arcflow_paths_supported, std::floor(ub + tolerances.absolute_tolerance)); } else { return false; } @@ -476,8 +475,7 @@ bool derive_potential(arc_flow_model_t& model, } } } - if (is_known(model.slope[l]) && - std::abs(model.slope[l]) > tolerances.absolute_tolerance) { + if (is_known(model.slope[l]) && std::abs(model.slope[l]) > tolerances.absolute_tolerance) { for (int k = begin; k < end; ++k) { const int from = model.arcs[k].from; if (is_known(model.phi[from])) { continue; } @@ -603,12 +601,10 @@ std::vector token_order(const arc_flow_model_t& model, }); std::vector ratio_class(model.n_labels, 0); for (size_t position = 1; position < ordered.size(); ++position) { - const int previous = ordered[position - 1]; - const int current = ordered[position]; - const _Float128 lhs = - (_Float128)model.slope[previous] * (_Float128)model.displacement[current]; - const _Float128 rhs = - (_Float128)model.slope[current] * (_Float128)model.displacement[previous]; + const int previous = ordered[position - 1]; + const int current = ordered[position]; + const _Float128 lhs = (_Float128)model.slope[previous] * (_Float128)model.displacement[current]; + const _Float128 rhs = (_Float128)model.slope[current] * (_Float128)model.displacement[previous]; const _Float128 difference = lhs > rhs ? lhs - rhs : rhs - lhs; const bool tied = difference <= (_Float128)tolerances.absolute_tolerance; ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); @@ -685,14 +681,11 @@ std::optional run_dp(const arc_flow_model_t& model, size_t candidate_count = 0; for (const auto& entry : current) { for (int k = 0; k < arcflow_paths_supported; ++k) { - const int node = entry.node[k]; - const auto begin = - std::lower_bound(arc_begin, arc_end, node, [](const arc_t& a, int v) { - return a.from < v; - }); - const auto end = std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { - return v < a.from; - }); + const int node = entry.node[k]; + const auto begin = std::lower_bound( + arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); + const auto end = + std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { return v < a.from; }); const size_t added = end - begin; if (added > candidate_limit - candidate_count) { return std::nullopt; } candidate_count += added; @@ -704,12 +697,11 @@ std::optional run_dp(const arc_flow_model_t& model, for (int i = 0; i < (int)current.size(); ++i) { const frontier_t& entry = current[i]; for (int k = 0; k < arcflow_paths_supported; ++k) { - const int node = entry.node[k]; + const int node = entry.node[k]; const auto begin = std::lower_bound( arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); - const auto end = std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { - return v < a.from; - }); + const auto end = + std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { return v < a.from; }); for (auto it = begin; it != end; ++it) { candidate_t candidate; candidate.front = entry; @@ -827,9 +819,9 @@ template arc_flow_t::~arc_flow_t() = default; template -bool arc_flow_t::recognize(const optimization_problem_t& op_problem, - const typename mip_solver_settings_t::tolerances_t& - tolerances) +bool arc_flow_t::recognize( + const optimization_problem_t& op_problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) { const i_t n_variables = op_problem.get_n_variables(); const i_t n_constraints = op_problem.get_n_constraints(); @@ -873,9 +865,9 @@ bool arc_flow_t::recognize(const optimization_problem_t& op_ } template -bool arc_flow_t::recognize(const problem_t& problem, - const typename mip_solver_settings_t::tolerances_t& - tolerances) +bool arc_flow_t::recognize( + const problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances) { const i_t n_variables = problem.n_variables; const i_t n_constraints = problem.n_constraints; @@ -947,8 +939,7 @@ bool arc_flow_t::solve( CUOPT_LOG_DEBUG("[ArcFlow] no complete path set found in the ordered family"); return false; } - CUOPT_LOG_DEBUG( - "[ArcFlow] search %s", result->exact ? "exact" : "beamed by the history budget"); + CUOPT_LOG_DEBUG("[ArcFlow] search %s", result->exact ? "exact" : "beamed by the history budget"); assignment.assign((size_t)h.n_variables, f_t{0}); for (int col : result->columns) { diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cuh b/cpp/src/mip_heuristics/structural/arc_flow.cuh index 11ae4d554a..9bb155637c 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cuh +++ b/cpp/src/mip_heuristics/structural/arc_flow.cuh @@ -26,10 +26,9 @@ class arc_flow_t : public structural_heuristic_t { bool recognize(const problem_t& problem, const typename mip_solver_settings_t::tolerances_t& tolerances) override; - bool solve( - const typename mip_solver_settings_t::tolerances_t& tolerances, - std::atomic& preemption, - std::vector& assignment) override; + bool solve(const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + std::vector& assignment) override; private: struct host_state_t; diff --git a/cpp/src/mip_heuristics/structural/early_structural.cu b/cpp/src/mip_heuristics/structural/early_structural.cu index 0b0269cdb7..0f64e49eff 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cu +++ b/cpp/src/mip_heuristics/structural/early_structural.cu @@ -37,8 +37,7 @@ static bool validate(problem_t& problem, template static std::unique_ptr> make_structural_heuristic( - const model_t& model, - const typename mip_solver_settings_t::tolerances_t& tolerances) + const model_t& model, const typename mip_solver_settings_t::tolerances_t& tolerances) { auto heuristic = std::make_unique>(); if (!heuristic->recognize(model, tolerances)) { return nullptr; } @@ -54,8 +53,8 @@ std::unique_ptr> early_structural_t::crea if (omp_get_num_threads() < CUOPT_MIP_EARLY_STRUCTURAL_REQUIRED_THREAD_COUNT) { return nullptr; } auto active = make_structural_heuristic(op_problem, tolerances); if (!active) { return nullptr; } - return std::unique_ptr( - new early_structural_t(op_problem, tolerances, std::move(incumbent_callback), std::move(active))); + return std::unique_ptr(new early_structural_t( + op_problem, tolerances, std::move(incumbent_callback), std::move(active))); } template diff --git a/cpp/src/mip_heuristics/structural/early_structural.cuh b/cpp/src/mip_heuristics/structural/early_structural.cuh index aac3447e79..883ec2e9fe 100644 --- a/cpp/src/mip_heuristics/structural/early_structural.cuh +++ b/cpp/src/mip_heuristics/structural/early_structural.cuh @@ -32,10 +32,9 @@ class structural_heuristic_t { return false; } - virtual bool solve( - const typename mip_solver_settings_t::tolerances_t& tolerances, - std::atomic& preemption, - std::vector& assignment) = 0; + virtual bool solve(const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption, + std::vector& assignment) = 0; }; template diff --git a/cpp/tests/mip/arc_flow_test.cu b/cpp/tests/mip/arc_flow_test.cu index 11293d414e..ac0a37408f 100644 --- a/cpp/tests/mip/arc_flow_test.cu +++ b/cpp/tests/mip/arc_flow_test.cu @@ -57,8 +57,8 @@ struct arc_t { double cost; }; -constexpr int n_labels = 3; -constexpr int n_paths = 2; +constexpr int n_labels = 3; +constexpr int n_paths = 2; constexpr double expected_objective = 6.0; constexpr std::array label_demand{2, 1, 1}; constexpr int demanded_arcs = label_demand[0] + label_demand[1] + label_demand[2]; @@ -86,14 +86,13 @@ built_model_t build_arc_flow(const build_options_t& opts = {}) double cost = arc.cost + opts.cost_intercept; if (arc.label == opts.negative_cost_label) { cost = -arc.cost; } if (arc.label == opts.perturbed_cost_label && arc.from == t1) { cost += 1.0; } - const double ub = opts.large_finite_capacities ? std::numeric_limits::max() - : label_demand[arc.label]; + const double ub = + opts.large_finite_capacities ? std::numeric_limits::max() : label_demand[arc.label]; columns.push_back( column_t{{{arc.from, 1.0}, {arc.to, -1.0}, {n_states + arc.label, 1.0}}, cost, ub}); } if (!opts.row_slack_terminators) { - const double ub = - opts.large_finite_capacities ? std::numeric_limits::max() : 1.0; + const double ub = opts.large_finite_capacities ? std::numeric_limits::max() : 1.0; for (const state_t state : terminator_states) { columns.push_back(column_t{{{state, 1.0}}, 0.0, ub}); } @@ -208,13 +207,12 @@ run_outcome_t run_heuristic(const built_model_t& model, input_options_t options { const raft::handle_t handle{}; optimization_problem_t problem(&handle); - problem.set_csr_constraint_matrix( - model.values.data(), - model.values.size(), - model.indices.data(), - model.indices.size(), - model.offsets.data(), - model.offsets.size()); + problem.set_csr_constraint_matrix(model.values.data(), + model.values.size(), + model.indices.data(), + model.indices.size(), + model.offsets.data(), + model.offsets.size()); problem.set_objective_coefficients(model.obj.data(), model.obj.size()); if (options.set_lower_bounds) { problem.set_variable_lower_bounds(model.var_lb.data(), model.var_lb.size()); @@ -290,15 +288,14 @@ TEST(arc_flow, uses_solver_integrality_tolerance_for_scaled_demand) constexpr int large_demand = 19998; constexpr double row_scale = 0.1; const double scaled_demand = (large_demand + normalized_offset) * row_scale; - model.row_lb[cover_row] = scaled_demand; + model.row_lb[cover_row] = scaled_demand; for (int k = model.offsets[cover_row]; k < model.offsets[cover_row + 1]; ++k) { model.values[k] = row_scale; } std::fill(model.var_ub.begin(), model.var_ub.end(), large_demand); const double normalized_demand = scaled_demand / row_scale; - const double integrality_error = - std::abs(normalized_demand - std::round(normalized_demand)); + const double integrality_error = std::abs(normalized_demand - std::round(normalized_demand)); EXPECT_GT(integrality_error, tolerances.absolute_tolerance); EXPECT_LE(integrality_error, tolerances.integrality_tolerance); From 2db3e972ef9e32d36f44b362b30578aead921788 Mon Sep 17 00:00:00 2001 From: yboucher Date: Tue, 1 Sep 2026 06:19:52 -0700 Subject: [PATCH 6/7] use templated integral and float types --- cpp/src/mip_heuristics/structural/arc_flow.cu | 531 +++++++++--------- 1 file changed, 276 insertions(+), 255 deletions(-) diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index 3cbcb16fcd..5c3fc96b0c 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -35,65 +35,76 @@ constexpr size_t arcflow_candidate_bytes_max = size_t{32} << 20; enum class row_role_t : uint8_t { flow, cover }; +template struct arc_t { - int from{-1}; - int to{-1}; - int label{-1}; - int col{-1}; - double cost{0.0}; + i_t from{-1}; + i_t to{-1}; + i_t label{-1}; + i_t col{-1}; + f_t cost{0}; }; +template struct arc_flow_model_t { - int n_nodes{0}; - int n_labels{0}; + i_t n_nodes{0}; + i_t n_labels{0}; - std::vector phi; - std::vector path_start; + std::vector phi; + std::vector path_start; std::vector demand; - std::vector displacement; - std::vector slope; - std::vector arc_offset; - std::vector arcs; + std::vector displacement; + std::vector slope; + std::vector arc_offset; + std::vector> arcs; // A negative terminator_col denotes conservation-row slack. - std::vector terminator_col; - std::vector terminator_cost; + std::vector terminator_col; + std::vector terminator_cost; std::vector terminator_capacity; }; +template struct frontier_t { - std::array node{}; - double cost{0.0}; + std::array node{}; + f_t cost{0}; }; +template struct parent_t { - int prev{-1}; - int arc{-1}; + i_t prev{-1}; + i_t arc{-1}; }; +template struct candidate_t { - frontier_t front; - parent_t parent; + frontier_t front; + parent_t parent; }; +template struct arc_flow_result_t { - std::vector columns; + std::vector columns; bool exact{true}; }; -bool is_known(double v) { return !std::isnan(v); } +template +bool is_known(f_t v) +{ + return !std::isnan(v); +} +template struct arcflow_profile_t { - arcflow_profile_t(int64_t n_variables = 0, int64_t n_constraints = 0) + arcflow_profile_t(i_t n_variables = 0, i_t n_constraints = 0) : col_entries(n_variables, 0), - row_min_mag(n_constraints, std::numeric_limits::infinity()), - row_max_mag(n_constraints, 0.0) + row_min_mag(n_constraints, std::numeric_limits::infinity()), + row_max_mag(n_constraints, 0) { } std::vector col_entries; - std::vector row_min_mag; - std::vector row_max_mag; + std::vector row_min_mag; + std::vector row_max_mag; }; template @@ -125,8 +136,8 @@ bool arcflow_accepts_bounds(const std::vector& row_lb, const std::vector& row_lb, const std::vector +template bool arcflow_accepts_profile(const host_problem_t& h, - const tolerance_t& tolerances, - arcflow_profile_t& p) + const typename mip_solver_settings_t::tolerances_t& tolerances, + arcflow_profile_t& p) { - p = arcflow_profile_t(h.n_variables, h.n_constraints); + p = arcflow_profile_t(h.n_variables, h.n_constraints); for (i_t r = 0; r < h.n_constraints; ++r) { for (i_t k = h.csr_offsets[r]; k < h.csr_offsets[r + 1]; ++k) { const i_t col = h.csr_cols[k]; cuopt_assert(col >= 0 && col < h.n_variables, "Column index out of range"); if (++p.col_entries[col] > arcflow_max_col_entries) { return false; } - const double mag = std::abs((double)h.csr_values[k]); + const f_t mag = std::abs(h.csr_values[k]); p.row_min_mag[r] = std::min(p.row_min_mag[r], mag); p.row_max_mag[r] = std::max(p.row_max_mag[r], mag); } } - double cover_demand = 0.0; + f_t cover_demand = 0; for (i_t r = 0; r < h.n_constraints; ++r) { - if (p.row_max_mag[r] == 0.0 || p.row_min_mag[r] <= tolerances.absolute_tolerance) { + if (p.row_max_mag[r] == 0 || p.row_min_mag[r] <= tolerances.absolute_tolerance) { return false; } if (p.row_max_mag[r] - p.row_min_mag[r] > tolerances.absolute_tolerance) { return false; } - if (!std::isfinite((double)h.row_ub[r])) { - const double demand = h.row_lb[r] / p.row_max_mag[r]; - if (!is_integer(demand, tolerances.integrality_tolerance) || - demand < 1.0 - tolerances.absolute_tolerance) { + if (!std::isfinite(h.row_ub[r])) { + const f_t demand = h.row_lb[r] / p.row_max_mag[r]; + if (!is_integer(demand, tolerances.integrality_tolerance) || + demand < 1 - tolerances.absolute_tolerance) { return false; } cover_demand += std::round(demand); } } - return cover_demand > 0.0 && cover_demand <= arcflow_max_tokens; + return cover_demand > 0 && cover_demand <= arcflow_max_tokens; } +template struct row_info_t { row_role_t role{row_role_t::cover}; - double scale{1.0}; - double lo{0.0}; - double hi{0.0}; + f_t scale{1}; + f_t lo{0}; + f_t hi{0}; }; -template +template bool classify_rows(const host_problem_t& h, - const arcflow_profile_t& profile, - const tolerance_t& tolerances, - std::vector& rows) + const arcflow_profile_t& profile, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::vector>& rows) { cuopt_assert((i_t)profile.row_min_mag.size() == h.n_constraints, "Size mismatch"); cuopt_assert((i_t)profile.row_max_mag.size() == h.n_constraints, "Size mismatch"); - rows.assign(h.n_constraints, row_info_t{}); + rows.assign(h.n_constraints, row_info_t{}); - for (int r = 0; r < h.n_constraints; ++r) { - row_info_t info; + for (i_t r = 0; r < h.n_constraints; ++r) { + row_info_t info; info.scale = profile.row_max_mag[r]; - const double lo = h.row_lb[r] / info.scale; - const double hi = h.row_ub[r] / info.scale; + const f_t lo = h.row_lb[r] / info.scale; + const f_t hi = h.row_ub[r] / info.scale; const bool lo_fin = std::isfinite(lo); const bool hi_fin = std::isfinite(hi); @@ -205,8 +217,8 @@ bool classify_rows(const host_problem_t& h, info.lo = lo; info.hi = hi; } else if (lo_fin) { - if (!is_integer(lo, tolerances.integrality_tolerance) || - lo < 1.0 - tolerances.absolute_tolerance) { + if (!is_integer(lo, tolerances.integrality_tolerance) || + lo < 1 - tolerances.absolute_tolerance) { return false; } info.role = row_role_t::cover; @@ -222,68 +234,69 @@ bool classify_rows(const host_problem_t& h, // The nonzero flow right-hand sides must all carry one sign. That sign says which incidence of an // arc is its tail, and their absolute sum is the number of paths. Returns 0 when ambiguous. -template -double supply_orientation(const std::vector& rows, - const tolerance_t& tolerances, - double& total) +template +f_t supply_orientation(const std::vector>& rows, + const typename mip_solver_settings_t::tolerances_t& tolerances, + f_t& total) { - const double absolute_tolerance = tolerances.absolute_tolerance; - double positive = 0.0; - double negative = 0.0; + const f_t absolute_tolerance = tolerances.absolute_tolerance; + f_t positive = 0; + f_t negative = 0; for (const auto& info : rows) { if (info.role != row_role_t::flow) { continue; } if (info.lo > absolute_tolerance) { positive += info.lo; } if (info.hi < -absolute_tolerance) { negative += -info.hi; } } - if (positive > absolute_tolerance && negative > absolute_tolerance) { return 0.0; } + if (positive > absolute_tolerance && negative > absolute_tolerance) { return 0; } if (positive > absolute_tolerance) { total = positive; - return 1.0; + return 1; } if (negative > absolute_tolerance) { total = negative; - return -1.0; + return -1; } - return 0.0; + return 0; } // One incidence per role at most, so a fourth column entry necessarily duplicates one of the three // and is rejected without counting entries. +template struct column_incidence_t { - int tail{-1}; - int head{-1}; - int label{-1}; + i_t tail{-1}; + i_t head{-1}; + i_t label{-1}; }; // A column carrying a single flow incidence is accepted only when that incidence is the arc's // tail: sources are read from the right-hand side, so the mirror encoding of an explicit // injection arc is out of scope here. -template +template bool build_structure(const host_problem_t& h, - std::vector& rows, - const tolerance_t& tolerances, - arc_flow_model_t& model) + std::vector>& rows, + const typename mip_solver_settings_t::tolerances_t& tolerances, + arc_flow_model_t& model) { - double supply_total = 0.0; - const double sign = supply_orientation(rows, tolerances, supply_total); - if (sign == 0.0 || !is_integer(supply_total, tolerances.integrality_tolerance)) { + f_t supply_total = 0; + const f_t sign = supply_orientation(rows, tolerances, supply_total); + if (sign == 0 || !is_integer(supply_total, tolerances.integrality_tolerance)) { return false; } if (std::round(supply_total) != arcflow_paths_supported) { return false; } // Reorient so a normalized coefficient of +1 always means "the arc leaves this node". - if (sign < 0.0) { + if (sign < 0) { for (auto& info : rows) { if (info.role != row_role_t::flow) { continue; } - const double lo = info.lo; - info.lo = -info.hi; - info.hi = -lo; + const f_t lo = info.lo; + info.lo = -info.hi; + info.hi = -lo; } } - std::vector node_of_row(h.n_constraints, -1); - std::vector label_of_row(h.n_constraints, -1); - for (int r = 0; r < h.n_constraints; ++r) { + std::vector node_of_row(h.n_constraints, -1); + std::vector label_of_row(h.n_constraints, -1); + for (i_t r = 0; r < h.n_constraints; ++r) { if (rows[r].role == row_role_t::flow) { node_of_row[r] = model.n_nodes++; } else { @@ -294,12 +307,12 @@ bool build_structure(const host_problem_t& h, model.demand.assign(model.n_labels, 0); model.terminator_col.assign(model.n_nodes, -1); - model.terminator_cost.assign(model.n_nodes, 0.0); + model.terminator_cost.assign(model.n_nodes, 0); model.terminator_capacity.assign(model.n_nodes, 0); int64_t total_demand = 0; - for (int r = 0; r < h.n_constraints; ++r) { - const int l = label_of_row[r]; + for (i_t r = 0; r < h.n_constraints; ++r) { + const i_t l = label_of_row[r]; if (l < 0) { continue; } model.demand[l] = std::round(rows[r].lo); total_demand += model.demand[l]; @@ -307,41 +320,41 @@ bool build_structure(const host_problem_t& h, if (total_demand <= 0 || total_demand > arcflow_max_tokens) { return false; } // Negative net outflow encodes path termination after singleton-column substitution. - for (int r = 0; r < h.n_constraints; ++r) { - const int v = node_of_row[r]; + for (i_t r = 0; r < h.n_constraints; ++r) { + const i_t v = node_of_row[r]; if (v < 0) { continue; } const auto& info = rows[r]; if (info.lo > tolerances.absolute_tolerance) { if (std::abs(info.lo - info.hi) > tolerances.absolute_tolerance || - !is_integer(info.lo, tolerances.integrality_tolerance)) { + !is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } model.path_start.insert(model.path_start.end(), (size_t)std::round(info.lo), v); } else if (std::abs(info.hi) <= tolerances.absolute_tolerance) { if (info.lo < -tolerances.absolute_tolerance) { - if (!is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } + if (!is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } model.terminator_capacity[v] = - std::min((double)arcflow_paths_supported, std::round(-info.lo)); + std::min((f_t)arcflow_paths_supported, std::round(-info.lo)); } } else { return false; } } - if ((int)model.path_start.size() != arcflow_paths_supported) { return false; } + if ((i_t)model.path_start.size() != arcflow_paths_supported) { return false; } - std::vector columns(h.n_variables); - for (int r = 0; r < h.n_constraints; ++r) { + std::vector> columns(h.n_variables); + for (i_t r = 0; r < h.n_constraints; ++r) { for (i_t k = h.csr_offsets[r]; k < h.csr_offsets[r + 1]; ++k) { const i_t j = h.csr_cols[k]; cuopt_assert(j >= 0 && j < h.n_variables, "Column index out of range"); - auto& column = columns[j]; - const double unit = h.csr_values[k] / rows[r].scale; + auto& column = columns[j]; + const f_t unit = h.csr_values[k] / rows[r].scale; if (rows[r].role == row_role_t::flow) { - const double oriented = unit * sign; - if (std::abs(oriented - 1.0) <= tolerances.absolute_tolerance) { + const f_t oriented = unit * sign; + if (std::abs(oriented - 1) <= tolerances.absolute_tolerance) { if (column.tail >= 0) { return false; } column.tail = node_of_row[r]; - } else if (std::abs(oriented + 1.0) <= tolerances.absolute_tolerance) { + } else if (std::abs(oriented + 1) <= tolerances.absolute_tolerance) { if (column.head >= 0) { return false; } column.head = node_of_row[r]; } else { @@ -349,29 +362,29 @@ bool build_structure(const host_problem_t& h, } } else { // A covering incidence is positive irrespective of the flow orientation. - if (std::abs(unit - 1.0) > tolerances.absolute_tolerance) { return false; } + if (std::abs(unit - 1) > tolerances.absolute_tolerance) { return false; } if (column.label >= 0) { return false; } column.label = label_of_row[r]; } } } - for (int j = 0; j < h.n_variables; ++j) { + for (i_t j = 0; j < h.n_variables; ++j) { if (h.var_types[j] != var_t::INTEGER) { return false; } - if (!std::isfinite((double)h.obj[j])) { return false; } - if (std::abs((double)h.var_lb[j]) > tolerances.absolute_tolerance) { return false; } - const double ub = h.var_ub[j]; - if (!std::isfinite(ub) || ub < 1.0 - tolerances.absolute_tolerance) { return false; } - - const int tail = columns[j].tail; - const int head = columns[j].head; - const int label = columns[j].label; + if (!std::isfinite(h.obj[j])) { return false; } + if (std::abs(h.var_lb[j]) > tolerances.absolute_tolerance) { return false; } + const f_t ub = h.var_ub[j]; + if (!std::isfinite(ub) || ub < 1 - tolerances.absolute_tolerance) { return false; } + + const i_t tail = columns[j].tail; + const i_t head = columns[j].head; + const i_t label = columns[j].label; if (tail >= 0 && head >= 0) { if (label < 0) { return false; } // With this bound the arc capacity can never bind: a use of the arc consumes one of the // label's tokens, and there are exactly demand[label] of them. - if ((double)model.demand[label] > ub + tolerances.absolute_tolerance) { return false; } - model.arcs.push_back(arc_t{tail, head, label, j, (double)h.obj[j]}); + if ((f_t)model.demand[label] > ub + tolerances.absolute_tolerance) { return false; } + model.arcs.push_back(arc_t{tail, head, label, j, h.obj[j]}); } else if (tail >= 0 && label < 0) { // Explicit loss arc: it leaves the node and never arrives, so it ends a path. A node that // already absorbs paths through row slack would need the two capacities apportioned, which @@ -380,7 +393,7 @@ bool build_structure(const host_problem_t& h, model.terminator_col[tail] = j; model.terminator_cost[tail] = h.obj[j]; model.terminator_capacity[tail] = - std::min((double)arcflow_paths_supported, std::floor(ub + tolerances.absolute_tolerance)); + std::min((f_t)arcflow_paths_supported, std::floor(ub + tolerances.absolute_tolerance)); } else { return false; } @@ -388,20 +401,22 @@ bool build_structure(const host_problem_t& h, if (model.arcs.empty()) { return false; } - std::stable_sort(model.arcs.begin(), model.arcs.end(), [](const arc_t& a, const arc_t& b) { - if (a.label != b.label) { return a.label < b.label; } - if (a.from != b.from) { return a.from < b.from; } - return a.cost < b.cost; - }); + std::stable_sort(model.arcs.begin(), + model.arcs.end(), + [](const arc_t& a, const arc_t& b) { + if (a.label != b.label) { return a.label < b.label; } + if (a.from != b.from) { return a.from < b.from; } + return a.cost < b.cost; + }); model.arc_offset.assign(model.n_labels + 1, 0); for (const auto& arc : model.arcs) { model.arc_offset[arc.label + 1]++; } - for (int l = 0; l < model.n_labels; ++l) { + for (i_t l = 0; l < model.n_labels; ++l) { model.arc_offset[l + 1] += model.arc_offset[l]; if (model.arc_offset[l] == model.arc_offset[l + 1]) { return false; } } - cuopt_assert(model.arc_offset.back() == (int)model.arcs.size(), + cuopt_assert(model.arc_offset.back() == (i_t)model.arcs.size(), "arc CSR offsets must cover every arc"); return true; } @@ -411,19 +426,19 @@ bool build_structure(const host_problem_t& h, // the potential on every node it touches, and the remaining labels are fitted and extended from // there. The result is an affine image of the true potential, which leaves the order that // consumes it unchanged, since a common positive factor cancels out of every comparison. -template -bool derive_potential(arc_flow_model_t& model, - const tolerance_t& tolerances, +template +bool derive_potential(arc_flow_model_t& model, + const typename mip_solver_settings_t::tolerances_t& tolerances, const std::atomic& preemption_flag) { - const int n_labels = model.n_labels; + const i_t n_labels = model.n_labels; - int reference = -1; + i_t reference = -1; size_t best_count = 0; - std::vector costs; - for (int l = 0; l < n_labels; ++l) { + std::vector costs; + for (i_t l = 0; l < n_labels; ++l) { costs.clear(); - for (int k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { + for (i_t k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { costs.push_back(model.arcs[k].cost); } std::sort(costs.begin(), costs.end()); @@ -435,13 +450,13 @@ bool derive_potential(arc_flow_model_t& model, } if (reference < 0 || best_count < 2) { return false; } - const double unknown = std::numeric_limits::quiet_NaN(); + const f_t unknown = std::numeric_limits::quiet_NaN(); model.phi.assign(model.n_nodes, unknown); model.slope.assign(n_labels, unknown); model.displacement.assign(n_labels, unknown); - std::vector intercept(n_labels, unknown); + std::vector intercept(n_labels, unknown); - for (int k = model.arc_offset[reference]; k < model.arc_offset[reference + 1]; ++k) { + for (i_t k = model.arc_offset[reference]; k < model.arc_offset[reference + 1]; ++k) { model.phi[model.arcs[k].from] = model.arcs[k].cost; } @@ -451,22 +466,22 @@ bool derive_potential(arc_flow_model_t& model, for (; rounds < max_rounds; ++rounds) { if (preemption_flag.load()) { return false; } bool progress = false; - for (int l = 0; l < n_labels; ++l) { - const int begin = model.arc_offset[l]; - const int end = model.arc_offset[l + 1]; + for (i_t l = 0; l < n_labels; ++l) { + const i_t begin = model.arc_offset[l]; + const i_t end = model.arc_offset[l + 1]; if (!is_known(model.slope[l])) { - int lowest = -1; - int highest = -1; - for (int k = begin; k < end; ++k) { - const double p = model.phi[model.arcs[k].from]; + i_t lowest = -1; + i_t highest = -1; + for (i_t k = begin; k < end; ++k) { + const f_t p = model.phi[model.arcs[k].from]; if (!is_known(p)) { continue; } if (lowest < 0 || p < model.phi[model.arcs[lowest].from]) { lowest = k; } if (highest < 0 || p > model.phi[model.arcs[highest].from]) { highest = k; } } if (lowest >= 0 && highest >= 0) { - const double lo_phi = model.phi[model.arcs[lowest].from]; - const double hi_phi = model.phi[model.arcs[highest].from]; + const f_t lo_phi = model.phi[model.arcs[lowest].from]; + const f_t hi_phi = model.phi[model.arcs[highest].from]; if (std::abs(lo_phi - hi_phi) > tolerances.absolute_tolerance) { model.slope[l] = (model.arcs[highest].cost - model.arcs[lowest].cost) / (hi_phi - lo_phi); @@ -476,8 +491,8 @@ bool derive_potential(arc_flow_model_t& model, } } if (is_known(model.slope[l]) && std::abs(model.slope[l]) > tolerances.absolute_tolerance) { - for (int k = begin; k < end; ++k) { - const int from = model.arcs[k].from; + for (i_t k = begin; k < end; ++k) { + const i_t from = model.arcs[k].from; if (is_known(model.phi[from])) { continue; } model.phi[from] = (model.arcs[k].cost - intercept[l]) / model.slope[l]; progress = true; @@ -485,9 +500,9 @@ bool derive_potential(arc_flow_model_t& model, } if (!is_known(model.displacement[l])) { - for (int k = begin; k < end; ++k) { - const double from = model.phi[model.arcs[k].from]; - const double to = model.phi[model.arcs[k].to]; + for (i_t k = begin; k < end; ++k) { + const f_t from = model.phi[model.arcs[k].from]; + const f_t to = model.phi[model.arcs[k].to]; if (is_known(from) && is_known(to)) { model.displacement[l] = to - from; progress = true; @@ -496,9 +511,9 @@ bool derive_potential(arc_flow_model_t& model, } } if (is_known(model.displacement[l])) { - for (int k = begin; k < end; ++k) { - const int from = model.arcs[k].from; - const int to = model.arcs[k].to; + for (i_t k = begin; k < end; ++k) { + const i_t from = model.arcs[k].from; + const i_t to = model.arcs[k].to; if (is_known(model.phi[from]) && !is_known(model.phi[to])) { model.phi[to] = model.phi[from] + model.displacement[l]; progress = true; @@ -513,15 +528,15 @@ bool derive_potential(arc_flow_model_t& model, } cuopt_assert(rounds < max_rounds, "propagation must reach a fixpoint within its progress bound"); - for (double p : model.phi) { + for (f_t p : model.phi) { if (!is_known(p)) { return false; } } - for (double p : model.displacement) { + for (f_t p : model.displacement) { if (!is_known(p)) { return false; } } - double phi_scale = 0.0; - for (double p : model.phi) { + f_t phi_scale = 0; + for (f_t p : model.phi) { phi_scale = std::max(phi_scale, std::abs(p)); } if (phi_scale <= tolerances.absolute_tolerance) { return false; } @@ -529,11 +544,11 @@ bool derive_potential(arc_flow_model_t& model, // Orient the potential so displacements are positive. A consistent potential whose // displacements are all strictly positive is exactly what makes the arc graph acyclic: a cycle // would need its displacements to sum to zero. - double displacement_sum = 0.0; - for (double p : model.displacement) { + f_t displacement_sum = 0; + for (f_t p : model.displacement) { displacement_sum += p; } - if (displacement_sum < 0.0) { + if (displacement_sum < 0) { for (auto& p : model.phi) { p = -p; } @@ -544,12 +559,12 @@ bool derive_potential(arc_flow_model_t& model, w = -w; } } - for (double p : model.displacement) { + for (f_t p : model.displacement) { if (p <= tolerances.absolute_tolerance) { return false; } } // Smith ordering assumes nonnegative job weights. - for (double w : model.slope) { + for (f_t w : model.slope) { if (is_known(w) && w < -tolerances.absolute_tolerance) { return false; } } @@ -557,34 +572,34 @@ bool derive_potential(arc_flow_model_t& model, // matters as much as the potential: the order is read off the fitted slopes, so a label whose // costs are not affine in the potential would be ordered on a meaningless quantity. for (const auto& arc : model.arcs) { - const double displaced = model.phi[arc.from] + model.displacement[arc.label]; + const f_t displaced = model.phi[arc.from] + model.displacement[arc.label]; if (std::abs(model.phi[arc.to] - displaced) > tolerances.absolute_tolerance) { return false; } if (!is_known(model.slope[arc.label])) { continue; } - const double predicted = model.slope[arc.label] * model.phi[arc.from] + intercept[arc.label]; + const f_t predicted = model.slope[arc.label] * model.phi[arc.from] + intercept[arc.label]; if (std::abs(predicted - arc.cost) > tolerances.absolute_tolerance) { return false; } } return true; } // Weighted shortest processing time orders labels by decreasing slope over displacement. -template -std::vector token_order(const arc_flow_model_t& model, - const tolerance_t& tolerances, +template +std::vector token_order(const arc_flow_model_t& model, + const typename mip_solver_settings_t::tolerances_t& tolerances, bool& all_slopes_identified) { - std::vector lowest_phi(model.n_labels, 0.0); - for (int l = 0; l < model.n_labels; ++l) { - double lowest = std::numeric_limits::infinity(); - for (int k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { + std::vector lowest_phi(model.n_labels, 0); + for (i_t l = 0; l < model.n_labels; ++l) { + f_t lowest = std::numeric_limits::infinity(); + for (i_t k = model.arc_offset[l]; k < model.arc_offset[l + 1]; ++k) { lowest = std::min(lowest, model.phi[model.arcs[k].from]); } lowest_phi[l] = lowest; } - std::vector ordered; - std::vector unidentified; + std::vector ordered; + std::vector unidentified; ordered.reserve(model.n_labels); - for (int l = 0; l < model.n_labels; ++l) { + for (i_t l = 0; l < model.n_labels; ++l) { if (is_known(model.slope[l])) { ordered.push_back(l); } else { @@ -593,23 +608,23 @@ std::vector token_order(const arc_flow_model_t& model, } // Approximate equality is not transitive. Tolerance forms ratio classes before the final sort. - std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { + std::sort(ordered.begin(), ordered.end(), [&](i_t a, i_t b) { const _Float128 lhs = (_Float128)model.slope[a] * (_Float128)model.displacement[b]; const _Float128 rhs = (_Float128)model.slope[b] * (_Float128)model.displacement[a]; if (lhs != rhs) { return lhs > rhs; } return a < b; }); - std::vector ratio_class(model.n_labels, 0); + std::vector ratio_class(model.n_labels, 0); for (size_t position = 1; position < ordered.size(); ++position) { - const int previous = ordered[position - 1]; - const int current = ordered[position]; + const i_t previous = ordered[position - 1]; + const i_t current = ordered[position]; const _Float128 lhs = (_Float128)model.slope[previous] * (_Float128)model.displacement[current]; const _Float128 rhs = (_Float128)model.slope[current] * (_Float128)model.displacement[previous]; const _Float128 difference = lhs > rhs ? lhs - rhs : rhs - lhs; const bool tied = difference <= (_Float128)tolerances.absolute_tolerance; ratio_class[current] = ratio_class[previous] + (tied ? 0 : 1); } - std::sort(ordered.begin(), ordered.end(), [&](int a, int b) { + std::sort(ordered.begin(), ordered.end(), [&](i_t a, i_t b) { if (ratio_class[a] != ratio_class[b]) { return ratio_class[a] < ratio_class[b]; } if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } if (model.displacement[a] != model.displacement[b]) { @@ -622,21 +637,21 @@ std::vector token_order(const arc_flow_model_t& model, for (int64_t d : model.demand) { total_demand += d; } - std::vector tokens; + std::vector tokens; tokens.reserve((size_t)total_demand); - for (int l : ordered) { + for (i_t l : ordered) { tokens.insert(tokens.end(), (size_t)model.demand[l], l); } // Labels without fitted slopes are placed at their first reachable potential. all_slopes_identified = unidentified.empty(); - std::sort(unidentified.begin(), unidentified.end(), [&](int a, int b) { + std::sort(unidentified.begin(), unidentified.end(), [&](i_t a, i_t b) { if (lowest_phi[a] != lowest_phi[b]) { return lowest_phi[a] < lowest_phi[b]; } return a < b; }); - for (int l : unidentified) { - double consumed = 0.0; - size_t slot = 0; + for (i_t l : unidentified) { + f_t consumed = 0; + size_t slot = 0; while (slot < tokens.size() && consumed < lowest_phi[l]) { consumed += model.displacement[tokens[slot]]; ++slot; @@ -646,46 +661,47 @@ std::vector token_order(const arc_flow_model_t& model, return tokens; } -std::optional run_dp(const arc_flow_model_t& model, - const std::vector& tokens, - const std::atomic& preemption_flag) +template +std::optional> run_dp(const arc_flow_model_t& model, + const std::vector& tokens, + const std::atomic& preemption_flag) { - const int n_tokens = tokens.size(); + const i_t n_tokens = tokens.size(); if (n_tokens == 0) { return std::nullopt; } - arc_flow_result_t result; + arc_flow_result_t result; // The reconstruction budget is charged against retained states at each level. size_t retained_bytes = 0; - std::vector> history; + std::vector>> history; history.reserve((size_t)n_tokens); - frontier_t root; - for (int k = 0; k < arcflow_paths_supported; ++k) { + frontier_t root; + for (i_t k = 0; k < arcflow_paths_supported; ++k) { root.node[k] = model.path_start[k]; } std::sort(root.node.begin(), root.node.end()); - std::vector current{root}; + std::vector> current{root}; - std::vector candidates; - std::vector next; - std::vector parents; - for (int t = 0; t < n_tokens; ++t) { + std::vector> candidates; + std::vector> next; + std::vector> parents; + for (i_t t = 0; t < n_tokens; ++t) { if (preemption_flag.load()) { return std::nullopt; } - const int label = tokens[t]; + const i_t label = tokens[t]; const auto arc_begin = model.arcs.begin() + model.arc_offset[label]; const auto arc_end = model.arcs.begin() + model.arc_offset[label + 1]; - const size_t candidate_limit = arcflow_candidate_bytes_max / sizeof(candidate_t); + const size_t candidate_limit = arcflow_candidate_bytes_max / sizeof(candidate_t); size_t candidate_count = 0; for (const auto& entry : current) { - for (int k = 0; k < arcflow_paths_supported; ++k) { - const int node = entry.node[k]; + for (i_t k = 0; k < arcflow_paths_supported; ++k) { + const i_t node = entry.node[k]; const auto begin = std::lower_bound( - arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); - const auto end = - std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { return v < a.from; }); + arc_begin, arc_end, node, [](const arc_t& a, i_t v) { return a.from < v; }); + const auto end = std::upper_bound( + begin, arc_end, node, [](i_t v, const arc_t& a) { return v < a.from; }); const size_t added = end - begin; if (added > candidate_limit - candidate_count) { return std::nullopt; } candidate_count += added; @@ -694,21 +710,21 @@ std::optional run_dp(const arc_flow_model_t& model, candidates.clear(); candidates.reserve(candidate_count); - for (int i = 0; i < (int)current.size(); ++i) { - const frontier_t& entry = current[i]; - for (int k = 0; k < arcflow_paths_supported; ++k) { - const int node = entry.node[k]; + for (i_t i = 0; i < (i_t)current.size(); ++i) { + const frontier_t& entry = current[i]; + for (i_t k = 0; k < arcflow_paths_supported; ++k) { + const i_t node = entry.node[k]; const auto begin = std::lower_bound( - arc_begin, arc_end, node, [](const arc_t& a, int v) { return a.from < v; }); - const auto end = - std::upper_bound(begin, arc_end, node, [](int v, const arc_t& a) { return v < a.from; }); + arc_begin, arc_end, node, [](const arc_t& a, i_t v) { return a.from < v; }); + const auto end = std::upper_bound( + begin, arc_end, node, [](i_t v, const arc_t& a) { return v < a.from; }); for (auto it = begin; it != end; ++it) { - candidate_t candidate; + candidate_t candidate; candidate.front = entry; candidate.front.node[k] = it->to; std::sort(candidate.front.node.begin(), candidate.front.node.end()); candidate.front.cost = entry.cost + it->cost; - candidate.parent = parent_t{i, (int)(it - model.arcs.begin())}; + candidate.parent = parent_t{i, (i_t)(it - model.arcs.begin())}; candidates.push_back(candidate); } } @@ -716,33 +732,38 @@ std::optional run_dp(const arc_flow_model_t& model, if (candidates.empty()) { return std::nullopt; } // A total order makes representative selection independent of enumeration order. - std::sort(candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - if (a.front.node != b.front.node) { return a.front.node < b.front.node; } - if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } - if (a.parent.prev != b.parent.prev) { return a.parent.prev < b.parent.prev; } - return a.parent.arc < b.parent.arc; - }); - candidates.erase(std::unique(candidates.begin(), - candidates.end(), - [](const candidate_t& a, const candidate_t& b) { - return a.front.node == b.front.node; - }), - candidates.end()); + std::sort(candidates.begin(), + candidates.end(), + [](const candidate_t& a, const candidate_t& b) { + if (a.front.node != b.front.node) { return a.front.node < b.front.node; } + if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } + if (a.parent.prev != b.parent.prev) { return a.parent.prev < b.parent.prev; } + return a.parent.arc < b.parent.arc; + }); + candidates.erase( + std::unique(candidates.begin(), + candidates.end(), + [](const candidate_t& a, const candidate_t& b) { + return a.front.node == b.front.node; + }), + candidates.end()); const size_t remaining = arcflow_history_bytes_max > retained_bytes ? arcflow_history_bytes_max - retained_bytes : 0; - const size_t affordable = std::max(remaining / sizeof(parent_t), 1); + const size_t affordable = std::max(remaining / sizeof(parent_t), 1); if (candidates.size() > affordable) { - std::stable_sort( - candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } - return a.front.node < b.front.node; - }); + std::stable_sort(candidates.begin(), + candidates.end(), + [](const candidate_t& a, const candidate_t& b) { + if (a.front.cost != b.front.cost) { return a.front.cost < b.front.cost; } + return a.front.node < b.front.node; + }); candidates.resize(affordable); - std::sort( - candidates.begin(), candidates.end(), [](const candidate_t& a, const candidate_t& b) { - return a.front.node < b.front.node; - }); + std::sort(candidates.begin(), + candidates.end(), + [](const candidate_t& a, const candidate_t& b) { + return a.front.node < b.front.node; + }); result.exact = false; } @@ -754,21 +775,21 @@ std::optional run_dp(const arc_flow_model_t& model, next.push_back(candidate.front); parents.push_back(candidate.parent); } - retained_bytes += parents.size() * sizeof(parent_t); + retained_bytes += parents.size() * sizeof(parent_t); history.push_back(std::move(parents)); current.swap(next); } - int best_index = -1; - double best_total = std::numeric_limits::infinity(); - for (int i = 0; i < (int)current.size(); ++i) { - const frontier_t& entry = current[i]; - double total = entry.cost; - bool closable = true; - for (int k = 0; k < arcflow_paths_supported && closable; ++k) { - const int node = entry.node[k]; + i_t best_index = -1; + f_t best_total = std::numeric_limits::infinity(); + for (i_t i = 0; i < (i_t)current.size(); ++i) { + const frontier_t& entry = current[i]; + f_t total = entry.cost; + bool closable = true; + for (i_t k = 0; k < arcflow_paths_supported && closable; ++k) { + const i_t node = entry.node[k]; int64_t sharing = 0; - for (int q = 0; q < arcflow_paths_supported; ++q) { + for (i_t q = 0; q < arcflow_paths_supported; ++q) { if (entry.node[q] == node) { sharing++; } } if (model.terminator_capacity[node] < sharing) { @@ -784,13 +805,13 @@ std::optional run_dp(const arc_flow_model_t& model, } if (best_index < 0) { return std::nullopt; } - for (int k = 0; k < arcflow_paths_supported; ++k) { - const int col = model.terminator_col[current[best_index].node[k]]; + for (i_t k = 0; k < arcflow_paths_supported; ++k) { + const i_t col = model.terminator_col[current[best_index].node[k]]; if (col >= 0) { result.columns.push_back(col); } } - int index = best_index; - for (int t = n_tokens; t > 0; --t) { - const parent_t& step = history[t - 1][index]; + i_t index = best_index; + for (i_t t = n_tokens; t > 0; --t) { + const parent_t& step = history[t - 1][index]; cuopt_assert(step.arc >= 0, "every level beyond the root records the arc it consumed"); result.columns.push_back(model.arcs[step.arc].col); index = step.prev; @@ -803,13 +824,13 @@ std::optional run_dp(const arc_flow_model_t& model, template struct arc_flow_t::host_state_t { - host_state_t(host_problem_t&& problem, arcflow_profile_t&& profile) + host_state_t(host_problem_t&& problem, arcflow_profile_t&& profile) : h(std::move(problem)), profile(std::move(profile)) { } host_problem_t h; - arcflow_profile_t profile; + arcflow_profile_t profile; }; template @@ -845,7 +866,7 @@ bool arc_flow_t::recognize( h.csr_values = cuopt::host_copy(op_problem.get_constraint_matrix_values(), stream); h.csr_cols = cuopt::host_copy(op_problem.get_constraint_matrix_indices(), stream); h.csr_offsets = cuopt::host_copy(op_problem.get_constraint_matrix_offsets(), stream); - arcflow_profile_t profile; + arcflow_profile_t profile; if (!arcflow_accepts_profile(h, tolerances, profile)) { return false; } h.obj = cuopt::host_copy(op_problem.get_objective_coefficients(), stream); @@ -886,7 +907,7 @@ bool arc_flow_t::recognize( h.row_ub = cuopt::host_copy(problem.constraint_upper_bounds, stream); if (!arcflow_accepts_bounds(h.row_lb, h.row_ub)) { return false; } - arcflow_profile_t profile; + arcflow_profile_t profile; if (!arcflow_accepts_profile(h, tolerances, profile)) { return false; } h.obj = cuopt::host_copy(problem.objective_coefficients, stream); @@ -906,13 +927,13 @@ bool arc_flow_t::solve( cuopt_assert(state_ != nullptr, "solve called without a successful recognize"); const auto& h = state_->h; - std::vector rows; + std::vector> rows; if (!classify_rows(h, state_->profile, tolerances, rows)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: rows are not unit incidence after normalization"); return false; } - arc_flow_model_t model; + arc_flow_model_t model; if (!build_structure(h, rows, tolerances, model)) { CUOPT_LOG_DEBUG("[ArcFlow] rejected: columns do not match the labelled arc pattern"); return false; @@ -928,8 +949,8 @@ bool arc_flow_t::solve( bool all_slopes_identified = true; const auto tokens = token_order(model, tolerances, all_slopes_identified); CUOPT_LOG_DEBUG("[ArcFlow] detected %d nodes, %d labels, %d paths, %zu tokens, ordering %s", - model.n_nodes, - model.n_labels, + (int)model.n_nodes, + (int)model.n_labels, arcflow_paths_supported, tokens.size(), all_slopes_identified ? "identified" : "partly by reachability"); @@ -942,7 +963,7 @@ bool arc_flow_t::solve( CUOPT_LOG_DEBUG("[ArcFlow] search %s", result->exact ? "exact" : "beamed by the history budget"); assignment.assign((size_t)h.n_variables, f_t{0}); - for (int col : result->columns) { + for (i_t col : result->columns) { assignment[col] += f_t{1}; } return true; From cc05205ce8ed92437da486d34a5a99ceef5431dc Mon Sep 17 00:00:00 2001 From: yboucher Date: Wed, 2 Sep 2026 05:05:25 -0700 Subject: [PATCH 7/7] style --- cpp/src/mip_heuristics/structural/arc_flow.cu | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/cpp/src/mip_heuristics/structural/arc_flow.cu b/cpp/src/mip_heuristics/structural/arc_flow.cu index 5c3fc96b0c..a5d0bc2941 100644 --- a/cpp/src/mip_heuristics/structural/arc_flow.cu +++ b/cpp/src/mip_heuristics/structural/arc_flow.cu @@ -152,9 +152,10 @@ bool arcflow_accepts_bounds(const std::vector& row_lb, const std::vector -bool arcflow_accepts_profile(const host_problem_t& h, - const typename mip_solver_settings_t::tolerances_t& tolerances, - arcflow_profile_t& p) +bool arcflow_accepts_profile( + const host_problem_t& h, + const typename mip_solver_settings_t::tolerances_t& tolerances, + arcflow_profile_t& p) { p = arcflow_profile_t(h.n_variables, h.n_constraints); for (i_t r = 0; r < h.n_constraints; ++r) { @@ -333,8 +334,7 @@ bool build_structure(const host_problem_t& h, } else if (std::abs(info.hi) <= tolerances.absolute_tolerance) { if (info.lo < -tolerances.absolute_tolerance) { if (!is_integer(info.lo, tolerances.integrality_tolerance)) { return false; } - model.terminator_capacity[v] = - std::min((f_t)arcflow_paths_supported, std::round(-info.lo)); + model.terminator_capacity[v] = std::min((f_t)arcflow_paths_supported, std::round(-info.lo)); } } else { return false; @@ -401,13 +401,12 @@ bool build_structure(const host_problem_t& h, if (model.arcs.empty()) { return false; } - std::stable_sort(model.arcs.begin(), - model.arcs.end(), - [](const arc_t& a, const arc_t& b) { - if (a.label != b.label) { return a.label < b.label; } - if (a.from != b.from) { return a.from < b.from; } - return a.cost < b.cost; - }); + std::stable_sort( + model.arcs.begin(), model.arcs.end(), [](const arc_t& a, const arc_t& b) { + if (a.label != b.label) { return a.label < b.label; } + if (a.from != b.from) { return a.from < b.from; } + return a.cost < b.cost; + }); model.arc_offset.assign(model.n_labels + 1, 0); for (const auto& arc : model.arcs) { model.arc_offset[arc.label + 1]++; @@ -583,9 +582,10 @@ bool derive_potential(arc_flow_model_t& model, // Weighted shortest processing time orders labels by decreasing slope over displacement. template -std::vector token_order(const arc_flow_model_t& model, - const typename mip_solver_settings_t::tolerances_t& tolerances, - bool& all_slopes_identified) +std::vector token_order( + const arc_flow_model_t& model, + const typename mip_solver_settings_t::tolerances_t& tolerances, + bool& all_slopes_identified) { std::vector lowest_phi(model.n_labels, 0); for (i_t l = 0; l < model.n_labels; ++l) {