diff --git a/.github/workflows/build_test_publish_images.yaml b/.github/workflows/build_test_publish_images.yaml index 8965c5e389..b4f92013b5 100644 --- a/.github/workflows/build_test_publish_images.yaml +++ b/.github/workflows/build_test_publish_images.yaml @@ -84,7 +84,9 @@ jobs: - name: Compute cuopt version id: compute-cuopt-ver run: | - ver=$(rapids-generate-version) + source rapids-datetime-string + # .post makes every nightly version unique (see ci/build_wheel.sh); ignored on release builds. + ver=$(RAPIDS_VERSION_SUFFIX=".post${RAPIDS_DATETIME_STRING}" rapids-generate-version) # Remove starting 0s from version 25.08.0a18 -> 25.8.0a18 CUOPT_VER=$(echo "$ver" | sed -E 's/\.0+([0-9])/\.\1/g') echo "CUOPT_VER=$CUOPT_VER" >> $GITHUB_OUTPUT diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index 776de19705..bd04d21d87 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 name: Trigger Nightly cuOpt Pipeline @@ -23,7 +23,6 @@ jobs: matrix: cuopt_branch: - "main" - - "release/26.08" steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: diff --git a/benchmarks/linear_programming/cuopt/run_cpufj.cu b/benchmarks/linear_programming/cuopt/run_cpufj.cu new file mode 100644 index 0000000000..67dafa847b --- /dev/null +++ b/benchmarks/linear_programming/cuopt/run_cpufj.cu @@ -0,0 +1,444 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "miplib2017_bks.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using i_t = int; +using f_t = double; +namespace mip = cuopt::mathematical_optimization::mip; + +using clk = std::chrono::high_resolution_clock; +double since(clk::time_point t0) +{ + return std::chrono::duration_cast>(clk::now() - t0).count(); +} + +struct climber_result_t { + bool crossed{false}; + double t_first{-1.0}; + f_t best_objective{std::numeric_limits::infinity()}; + i_t iterations{0}; + double seconds{0.0}; +}; + +void pin_to_core(int core) +{ + cpu_set_t set; + CPU_ZERO(&set); + CPU_SET(core, &set); + pthread_setaffinity_np(pthread_self(), sizeof(set), &set); +} + +// The CPUs this process is actually permitted to run on. A cgroup mask can be non-contiguous, so +// indexing hardware_concurrency() directly would collide several climbers onto one core. +std::vector allowed_cpus() +{ + std::vector allowed; + cpu_set_t set; + CPU_ZERO(&set); + if (sched_getaffinity(0, sizeof(set), &set) == 0) { + for (int cpu = 0; cpu < CPU_SETSIZE; ++cpu) { + if (CPU_ISSET(cpu, &set)) allowed.push_back(cpu); + } + } + if (allowed.empty()) allowed.push_back(0); + return allowed; +} + +void run_climber(mip::fj_cpu_climber_t* climber, + f_t time_limit, + int core, + climber_result_t& result) +{ + pin_to_core(core); + const auto t0 = clk::now(); + + climber->improvement_callback = [&result, t0](f_t objective, const std::vector&, double) { + if (!result.crossed) { + result.crossed = true; + result.t_first = since(t0); + } + result.best_objective = objective; + }; + + mip::cpufj_solve(climber, time_limit); + + result.seconds = since(t0); + result.iterations = climber->iterations; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc < 2) { + std::fprintf(stderr, "usage: %s [time_limit_s=60] [climbers=16] [seed=12345]\n", + argv[0]); + return 2; + } + const std::string path = argv[1]; + const f_t time_limit = argc > 2 ? std::atof(argv[2]) : 60.0; + const int n_climbers = argc > 3 ? std::atoi(argv[3]) : 16; + const unsigned base_seed = argc > 4 ? (unsigned)std::atoll(argv[4]) : 12345u; + + // Console sink so the engine's end-of-solve incumbent audit is visible, as solve_MIP does it. + cuopt::init_logger_t log_guard("", true); + + raft::handle_t handle; + + const auto mps_data_model = cuopt::mathematical_optimization::io::read_mps(path, false); + const auto op_problem = + cuopt::mathematical_optimization::mps_data_model_to_optimization_problem( + &handle, mps_data_model); + mip::problem_t problem(op_problem); + + // Anonymise the instance before anything under evolution can see it. + // + // problem_t exposes var_names, row_names and objective_name as public members, and + // the FJ code receives problem_t&. For a fixed benchmark set those strings are an + // exact fingerprint -- row_names[0] alone identifies most MIPLIB instances -- so a + // candidate could branch on identity and return a memorised objective. Reading the + // MODEL is intended and useful: coefficients, bounds, variable types, sparsity and + // row structure are all untouched here, so recognising set-packing rows, knapsack + // substructure or GUB constraints still works exactly as before. Only the labels go. + // + // Each string is cleared in place rather than the vectors being emptied, so size() + // and indexing stay valid and any code that walks names by variable index still + // works -- it just gets empty strings. + // + // This file is outside target_code and is sha256-gated by evaluate.py's FROZEN_FILES, + // so a candidate cannot restore the names. Do not move this below the solve. + for (auto& name : problem.var_names) name.clear(); + for (auto& name : problem.row_names) name.clear(); + problem.objective_name.clear(); + + std::printf("instance: %s n_vars=%d n_cstrs=%d nnz=%d\n", + path.c_str(), + problem.n_variables, + problem.n_constraints, + problem.nnz); + + // Taken from the host-side parse, so it is independent of everything under target_code. + { + const auto& col_indices = mps_data_model.get_constraint_matrix_indices(); + const auto& row_lb = mps_data_model.get_constraint_lower_bounds(); + const auto& row_ub = mps_data_model.get_constraint_upper_bounds(); + const int64_t nnz = (int64_t)col_indices.size(); + + const i_t n_cols = mps_data_model.get_n_variables(); + std::vector degree(n_cols, 0); + for (i_t index : col_indices) { + if (index >= 0 && index < n_cols) ++degree[index]; + } + std::sort(degree.begin(), degree.end()); + + const i_t max_degree = degree.empty() ? 0 : degree.back(); + auto quantile = [&](double q) { + return degree.empty() + ? 0 + : degree[std::min(degree.size() - 1, (size_t)(q * degree.size()))]; + }; + int64_t top10 = 0; + for (size_t k = 0; k < 10 && k < degree.size(); ++k) + top10 += degree[degree.size() - 1 - k]; + const double mean_degree = n_cols > 0 ? (double)nnz / n_cols : 0.0; + std::printf("census cols: n=%d degree max=%d p99=%d p90=%d median=%d mean=%.1f" + " widest=%.1f%% top10=%.1f%% of nnz hub=%.0fx mean\n", + n_cols, + max_degree, + quantile(0.99), + quantile(0.90), + quantile(0.50), + mean_degree, + nnz > 0 ? 100.0 * max_degree / nnz : 0.0, + nnz > 0 ? 100.0 * top10 / nnz : 0.0, + mean_degree > 0 ? max_degree / mean_degree : 0.0); + + const i_t n_rows = (i_t)std::min(row_lb.size(), row_ub.size()); + i_t lb_only = 0, ub_only = 0, equality = 0, ranged = 0, free_rows = 0; + for (i_t r = 0; r < n_rows; ++r) { + const bool has_lb = std::isfinite((double)row_lb[r]); + const bool has_ub = std::isfinite((double)row_ub[r]); + if (has_lb && has_ub) { + ++(row_lb[r] == row_ub[r] ? equality : ranged); + } else if (has_lb) { + ++lb_only; + } else if (has_ub) { + ++ub_only; + } else { + ++free_rows; + } + } + std::printf("census rows: n=%d lb_only=%d ub_only=%d equality=%d ranged=%d free=%d" + " one_sided=%.1f%%\n", + n_rows, + lb_only, + ub_only, + equality, + ranged, + free_rows, + n_rows > 0 ? 100.0 * (lb_only + ub_only) / n_rows : 0.0); + } + + // FROZEN -- defines t=0 for the benchmark. Everything above it (the MPS parse, + // problem construction under problem/, and the name anonymisation) is outside + // target_code; everything below it is editable. A marker any later would leave + // editable code ahead of the clock, which is somewhere to do unmeasured work; any + // earlier would charge the budget for a parse and a CUDA context no candidate can + // influence. + CUOPT_LOG_INFO("CPUFJ solve window start"); + + // Shared by every climber. Built by build_start_assignment, which is editable -- + // this driver is not. + mip::solution_t solution(problem); + mip::build_start_assignment(problem, solution, &handle); + + std::vector> preemption_flags(n_climbers); + std::vector>> climbers(n_climbers); + // Composition and per-climber parameters come from build_climber_portfolio, which + // is editable. The log prefix is assigned here and not there, so every climber + // stays identifiable in the log whatever the portfolio does. + mip::build_climber_portfolio(problem, solution, preemption_flags, climbers, base_seed); + for (int k = 0; k < n_climbers; ++k) { + climbers[k]->log_prefix = "[climber " + std::to_string(k) + "] "; + } + + const std::vector cpus = allowed_cpus(); + std::printf("running %d climbers x %.0fs, base seed %u, %zu allowed CPUs (%d..%d)\n", + n_climbers, (double)time_limit, base_seed, cpus.size(), cpus.front(), cpus.back()); + + std::vector results(n_climbers); + std::vector threads; + threads.reserve(n_climbers); + const auto wall0 = clk::now(); + for (int k = 0; k < n_climbers; ++k) { + threads.emplace_back( + run_climber, climbers[k].get(), time_limit, cpus[k % cpus.size()], std::ref(results[k])); + } + for (auto& t : threads) { + t.join(); + } + const double wall = since(wall0); + + int crossed = 0; + double sum_iters = 0; + f_t best_overall = std::numeric_limits::infinity(); + std::printf("\n climber | crossed | t_first(s) | obj | iters | iters/s\n"); + std::printf("---------+---------+------------+--------------+----------+---------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& r = results[k]; + sum_iters += r.iterations; + if (r.crossed) { + ++crossed; + best_overall = std::min(best_overall, r.best_objective); + } + std::printf(" %7d | %7s | %10s | %12.6g | %8d | %8.0f\n", + k, + r.crossed ? "YES" : "no", + r.crossed ? std::to_string(r.t_first).c_str() : "-", + r.crossed ? (double)r.best_objective : 0.0, + r.iterations, + r.seconds > 0 ? r.iterations / r.seconds : 0.0); + } + // Runs after the measured window closes, so its cost is off the clock. + // Solver space is always a minimisation, so beating the best known is always a smaller value. + const auto bks_user = cuopt_bench::lookup_miplib_bks(path); + const double bks = bks_user ? (double)problem.get_solver_obj_from_user_obj((f_t)*bks_user) : 0.0; + const double bks_slack = std::max(1e-6, std::fabs(bks) * 1e-9); + + int audited = 0, invalid = 0; + std::printf("\n climber | viol rows worst/tol | bnd viol worst/tol | int viol worst/tol |" + " obj drift rel | vs bks\n"); + std::printf("---------+----------------------+---------------------+---------------------+" + "----------------------+----------\n"); + for (int k = 0; k < n_climbers; ++k) { + auto& c = *climbers[k]; + if (c.feasible_found != results[k].crossed) { + std::printf(" %7d | feasible_found=%d disagrees with a reported incumbent=%d\n", + k, + (int)c.feasible_found, + (int)results[k].crossed); + ++invalid; + continue; + } + if (!c.feasible_found) continue; + ++audited; + + const double int_tol = c.view.pb.tolerances.integrality_tolerance; + + i_t rows_over = 0; + double worst_row_ratio = 0.0; + for (i_t r = 0; r < c.view.pb.n_constraints; ++r) { + __float128 activity = 0; + for (i_t j = c.h_offsets[r]; j < c.h_offsets[r + 1]; ++j) { + const i_t var = c.h_variables[j]; + const double coefficient = c.h_coefficients[j]; + const double value = c.h_best_assignment[var]; + activity += (__float128)coefficient * (__float128)value; + } + + const f_t lb = c.h_cstr_lb[r]; + const f_t ub = c.h_cstr_ub[r]; + const __float128 below = (__float128)lb - activity; + const __float128 above = activity - (__float128)ub; + const double excess = (double)std::max(std::max(below, above), (__float128)0); + if (excess <= 0.0) continue; + + const double tol = c.view.get_corrected_tolerance(r, lb, ub); + const double ratio = tol > 0 ? excess / tol : std::numeric_limits::infinity(); + if (ratio > 1.0) ++rows_over; + worst_row_ratio = std::max(worst_row_ratio, ratio); + } + + i_t bounds_over = 0; + i_t integers_over = 0; + double worst_bound_ratio = 0.0; + double worst_integer_ratio = 0.0; + __float128 objective = 0; + for (i_t v = 0; v < c.view.pb.n_variables; ++v) { + auto bounds = c.h_var_bounds[v].get(); + const double x = (double)c.h_best_assignment[v]; + const double out = std::max( + std::max((double)cuopt::get_lower(bounds) - x, x - (double)cuopt::get_upper(bounds)), 0.0); + if (out > int_tol) ++bounds_over; + worst_bound_ratio = std::max(worst_bound_ratio, int_tol > 0 ? out / int_tol : 0.0); + + if (c.view.pb.is_integer_var(v)) { + const double residual = std::fabs(x - std::round(x)); + if (residual > int_tol) ++integers_over; + worst_integer_ratio = std::max(worst_integer_ratio, int_tol > 0 ? residual / int_tol : 0.0); + } + const double coefficient = c.h_obj_coeffs[v]; + objective += (__float128)coefficient * (__float128)x; + } + + // Differenced before narrowing; the drift is smaller than a double ulp of the sum. + const __float128 difference = objective - (__float128)results[k].best_objective; + const double drift = (double)(difference < 0 ? -difference : difference); + const double exact = (double)objective; + const double scale = std::max(std::fabs(exact), 1.0); + const bool below_bks = bks_user && exact < bks - bks_slack; + const bool bad = rows_over > 0 || bounds_over > 0 || integers_over > 0 || below_bks; + if (bad) ++invalid; + std::printf(" %7d | %9d %10.3g | %8d %10.3g | %8d %10.3g | %12.3g %6.1e | %9.3g%s%s\n", + k, + rows_over, + worst_row_ratio, + bounds_over, + worst_bound_ratio, + integers_over, + worst_integer_ratio, + drift, + drift / scale, + bks_user ? exact - bks : 0.0, + below_bks ? " BELOW BKS" : "", + bad ? " INVALID" : ""); + } + std::printf("AUDIT: %d/%d reporting climbers checked, %d invalid, bks %s\n", + audited, + crossed, + invalid, + bks_user ? std::to_string(*bks_user).c_str() + : (cuopt_bench::is_known_infeasible(path) ? "known infeasible" : "unknown")); + + std::printf("\n climber | moves | apply nnz | nnz/move | bitmap elems | ratio |" + " bump/apply | bump/weight | mtm inval | cache hit%%\n"); + std::printf("---------+-----------+------------+----------+--------------+-------+" + "------------+-------------+-----------+-----------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + const int64_t bitmap = 2 * c.n_moves_applied * (int64_t)c.view.pb.n_variables; + const int64_t probes = c.hit_count + c.miss_count; + std::printf(" %7d | %9lld | %10lld | %8.1f | %12lld | %5.0f | %10lld | %11lld | %9lld |" + " %9.2f\n", + k, + (long long)c.n_moves_applied, + (long long)c.apply_move_nnz, + c.n_moves_applied > 0 ? (double)c.apply_move_nnz / c.n_moves_applied : 0.0, + (long long)bitmap, + c.apply_move_nnz > 0 ? (double)bitmap / c.apply_move_nnz : 0.0, + (long long)c.n_version_bumps_apply, + (long long)c.n_version_bumps_weights, + (long long)c.n_mtm_cache_invalidations, + probes > 0 ? 100.0 * c.hit_count / probes : 0.0); + } + + std::printf("\n climber | mtm calls | row entries | ent/call | capped ent | capped/call |" + " score calls | score nnz | nnz/score | nnz budget\n"); + std::printf("---------+-----------+-------------+----------+-------------+-------------+" + "-------------+-----------+-----------+-----------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + std::printf(" %7d | %9lld | %11lld | %8.0f | %11lld | %11.0f | %11lld | %9lld | %9.1f |" + " %10d\n", + k, + (long long)c.n_mtm_calls, + (long long)c.mtm_row_entries, + c.n_mtm_calls > 0 ? (double)c.mtm_row_entries / c.n_mtm_calls : 0.0, + (long long)c.mtm_entries_capped, + c.n_mtm_calls > 0 ? (double)c.mtm_entries_capped / c.n_mtm_calls : 0.0, + (long long)c.n_compute_score_calls, + (long long)c.compute_score_nnz, + c.n_compute_score_calls > 0 + ? (double)c.compute_score_nnz / c.n_compute_score_calls + : 0.0, + c.nnz_samples); + } + + std::printf("\n climber | refresh period | lhs total | periodic | bigval | perturb | restart |" + " epi vars | epi projections\n"); + std::printf("---------+----------------+-----------+----------+--------+---------+---------+" + "----------+----------------\n"); + for (int k = 0; k < n_climbers; ++k) { + const auto& c = *climbers[k]; + std::printf(" %7d | %14d | %9lld | %8lld | %6lld | %7lld | %7lld | %8zu | %15lld\n", + k, + c.lhs_refresh_period_used, + (long long)c.n_lhs_recompute_total, + (long long)c.n_lhs_recompute_periodic, + (long long)c.n_lhs_recompute_bigval, + (long long)c.n_lhs_recompute_perturb, + (long long)c.n_lhs_recompute_restart, + c.epigraph_vars.size(), + (long long)c.n_epigraph_projections); + } + + std::printf("\nSUMMARY: %d/%d crossed (%.0f%%) wall=%.1fs total_iters=%.0f agg_iters/s=%.0f\n", + crossed, + n_climbers, + 100.0 * crossed / n_climbers, + wall, + sum_iters, + wall > 0 ? sum_iters / wall : 0.0); + if (crossed > 0) { std::printf("BEST OBJECTIVE: %.10g\n", (double)best_overall); } + return 0; +} diff --git a/benchmarks/linear_programming/cuopt/run_mip.cpp b/benchmarks/linear_programming/cuopt/run_mip.cpp index 98cd9a56d2..6a9a3303fd 100644 --- a/benchmarks/linear_programming/cuopt/run_mip.cpp +++ b/benchmarks/linear_programming/cuopt/run_mip.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -136,6 +137,52 @@ std::vector> read_solution_from_dir(const std::string file_p return initial_solutions; } +struct incumbent_record_t { + double objective; + double work_timestamp; + double wall_time; +}; + +class incumbent_tracker_t : public cuopt::internals::get_solution_callback_t { + public: + explicit incumbent_tracker_t(std::chrono::high_resolution_clock::time_point start_time) + : start_time_(start_time) + { + } + + void get_solution(void* /*data*/, + void* cost, + void* /*solution_bound*/, + void* /*user_data*/) override + { + const auto now = std::chrono::high_resolution_clock::now(); + records_.push_back({*static_cast(cost), + 0.0, + std::chrono::duration(now - start_time_).count()}); + } + + void write_csv(const std::string& path) const + { + std::ofstream file(path); + if (!file.is_open()) { + std::cerr << "Error opening incumbent file " << path << std::endl; + return; + } + file << "index,objective,work_timestamp,wall_time_s\n"; + for (size_t i = 0; i < records_.size(); ++i) { + file << i << "," << std::setprecision(15) << records_[i].objective << "," + << records_[i].work_timestamp << "," << std::setprecision(6) << records_[i].wall_time + << "\n"; + } + } + + size_t size() const { return records_.size(); } + + private: + std::chrono::high_resolution_clock::time_point start_time_; + std::vector records_; +}; + int run_single_file(std::string file_path, int device, int batch_id, @@ -151,6 +198,8 @@ int run_single_file(std::string file_path, double work_limit, bool deterministic) { + (void)cudaFree(0); + const raft::handle_t handle_{}; cuopt::mathematical_optimization::mip_solver_settings_t settings; std::string base_filename = file_path.substr(file_path.find_last_of("/\\") + 1); @@ -218,6 +267,8 @@ int run_single_file(std::string file_path, cuopt::mathematical_optimization::benchmark_info_t benchmark_info; settings.benchmark_info_ptr = &benchmark_info; auto start_run_solver = std::chrono::high_resolution_clock::now(); + incumbent_tracker_t incumbent_tracker(start_run_solver); + settings.set_mip_callback(&incumbent_tracker); auto solution = cuopt::mathematical_optimization::solve_mip(&handle_, mps_data_model, settings); CUOPT_LOG_INFO( "first obj: %f last improvement of best feasible: %f last improvement after recombination: %f", @@ -291,6 +342,13 @@ int run_single_file(std::string file_path, << "\n"; write_to_output_file(out_dir, base_filename, device, n_gpus, batch_id, ss.str()); CUOPT_LOG_INFO("Results written to the file %s", base_filename.c_str()); + if (out_dir != "") { + std::string csv_path = + out_dir + "/" + base_filename.substr(0, base_filename.find(".mps")) + "_incumbents.csv"; + incumbent_tracker.write_csv(csv_path); + CUOPT_LOG_INFO( + "Incumbent trace (%zu entries) written to %s", incumbent_tracker.size(), csv_path.c_str()); + } return sol_found; } diff --git a/ci/docker/Dockerfile b/ci/docker/Dockerfile index 33ccd9d3ed..bd63e4337e 100644 --- a/ci/docker/Dockerfile +++ b/ci/docker/Dockerfile @@ -33,7 +33,12 @@ RUN apt-get update \ gnupg2 \ wget \ unzip \ - && add-apt-repository ppa:deadsnakes/ppa \ + && ( for i in 1 2 3 4 5; do \ + add-apt-repository -y ppa:deadsnakes/ppa && exit 0; \ + echo "add-apt-repository failed (attempt ${i}/5), retrying in 5s..."; \ + sleep 5; \ + done; \ + exit 1 ) \ && apt-get install -y --no-install-recommends \ python${PYTHON_SHORT_VER} \ python${PYTHON_SHORT_VER}-dev \ diff --git a/ci/validate_wheel.sh b/ci/validate_wheel.sh index 685d4b920d..a45c97b415 100755 --- a/ci/validate_wheel.sh +++ b/ci/validate_wheel.sh @@ -22,7 +22,7 @@ PYDISTCHECK_ARGS=( if [[ "${package_dir}" == "python/libcuopt" ]]; then if [[ "${RAPIDS_CUDA_MAJOR}" == "12" ]]; then PYDISTCHECK_ARGS+=( - --max-allowed-size-compressed '690Mi' + --max-allowed-size-compressed '695Mi' ) else PYDISTCHECK_ARGS+=( diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 1bef3a15cd..72d03b3d2d 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -49,6 +49,7 @@ rapids_cmake_build_type(Release) option(CMAKE_CUDA_LINEINFO "Enable the -lineinfo option for nvcc useful for cuda-memcheck / profiler" ON) option(BUILD_TESTS "Configure CMake to build tests" ON) option(BUILD_LP_ONLY "Build only linear programming components, exclude routing and MIP-specific files" OFF) +option(BUILD_MIP_BENCHMARKS "Build MIP benchmarks" OFF) option(SKIP_C_PYTHON_ADAPTERS "Skip building C and Python adapter files (cython_solve.cu and cuopt_c.cpp)" OFF) option(SKIP_ROUTING_BUILD "Skip building routing components" OFF) option(SKIP_GRPC_BUILD "Skip building gRPC and protobuf components" OFF) @@ -277,7 +278,7 @@ FetchContent_Declare( # This is the reason we are using the development branch # from Oct 12, 2025. Once these changes are merged into the main branch, #we can switch to the main branch. - GIT_TAG "32b3a87dbf4955d5a2803be74145c389ea31434d" + GIT_TAG "55d5edece584885061639ecf3a6eb8a4629be9f2" GIT_PROGRESS TRUE EXCLUDE_FROM_ALL SYSTEM @@ -307,6 +308,25 @@ set(BUILD_SHARED_LIBS OFF) FetchContent_MakeAvailable(pslp) set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) +FetchContent_Declare( + highway + GIT_REPOSITORY "https://github.com/google/highway.git" + GIT_TAG "1.4.0" + GIT_PROGRESS TRUE + EXCLUDE_FROM_ALL + SYSTEM +) + +set(HWY_ENABLE_CONTRIB OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_EXAMPLES OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_TESTS OFF CACHE BOOL "" FORCE) +set(HWY_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) + +set(BUILD_SHARED_LIBS_SAVED ${BUILD_SHARED_LIBS}) +set(BUILD_SHARED_LIBS OFF) +FetchContent_MakeAvailable(highway) +set(BUILD_SHARED_LIBS ${BUILD_SHARED_LIBS_SAVED}) + # dejavu - header-only graph automorphism library for MIP symmetry detection # https://github.com/markusa4/dejavu (header-only, skip its CMakeLists.txt) @@ -666,6 +686,7 @@ target_include_directories(cuopt_objs PRIVATE target_include_directories(cuopt_objs SYSTEM PRIVATE "${pslp_SOURCE_DIR}/include" "${dejavu_SOURCE_DIR}" + "${highway_SOURCE_DIR}" ) target_include_directories(cuopt_objs @@ -691,6 +712,9 @@ target_include_directories(cuopt_objs target_link_libraries(cuopt_objs PRIVATE $) add_dependencies(cuopt_objs PSLP) +target_link_libraries(cuopt_objs PRIVATE $) +add_dependencies(cuopt_objs hwy) + # Link KaMinPar by file to avoid export dependency tracking (mirrors PSLP above). # KaMinPar is a from-source static library fully embedded into libcuopt.so; it is never # installed (INSTALL_KAMINPAR OFF) and consumers of cuopt::cuopt never use it, so it must @@ -777,6 +801,9 @@ target_link_libraries(cuopt_objs # - generate tests -------------------------------------------------------------------------------- if (BUILD_TESTS) include(CTest) +endif () + +if (BUILD_TESTS OR (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY)) add_library(cuopt_static STATIC $) target_link_libraries(cuopt_static PUBLIC @@ -813,10 +840,15 @@ if (BUILD_TESTS) ) target_link_libraries(cuopt_static PRIVATE $) add_dependencies(cuopt_static PSLP) + target_link_libraries(cuopt_static PRIVATE $) + add_dependencies(cuopt_static hwy) target_link_libraries(cuopt_static PRIVATE $) if (TARGET KaMinPar) add_dependencies(cuopt_static KaMinPar) endif () +endif () + +if (BUILD_TESTS) add_subdirectory(tests) endif (BUILD_TESTS) @@ -857,6 +889,8 @@ target_link_libraries(cuopt ) target_link_libraries(cuopt PRIVATE $) add_dependencies(cuopt PSLP) +target_link_libraries(cuopt PRIVATE $) +add_dependencies(cuopt hwy) target_link_libraries(cuopt PRIVATE $) if (TARGET KaMinPar) add_dependencies(cuopt KaMinPar) @@ -1031,7 +1065,6 @@ if (NOT BUILD_LP_ONLY) endif () -option(BUILD_MIP_BENCHMARKS "Build MIP benchmarks" OFF) if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) add_executable(solve_MIP ../benchmarks/linear_programming/cuopt/run_mip.cpp) target_include_directories(solve_MIP @@ -1065,6 +1098,31 @@ if (BUILD_MIP_BENCHMARKS AND NOT BUILD_LP_ONLY) "${CMAKE_CURRENT_SOURCE_DIR}/src" ) + # CPU FJ standalone portfolio benchmark + add_executable(solve_CPUFJ ../benchmarks/linear_programming/cuopt/run_cpufj.cu) + set_target_properties(solve_CPUFJ PROPERTIES CXX_SCAN_FOR_MODULES OFF) + target_compile_options(solve_CPUFJ + PRIVATE "$<$:${CUOPT_CXX_FLAGS}>" + "$<$:${CUOPT_CUDA_FLAGS}>" + "$<$:-fopenmp>" + ) + target_link_libraries(solve_CPUFJ + PUBLIC + cuopt_static + OpenMP::OpenMP_CXX + OpenMP::OpenMP_CUDA + ) + target_include_directories(solve_CPUFJ + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + "${papilo_SOURCE_DIR}/src" + "${papilo_BINARY_DIR}" + ) + target_include_directories(solve_CPUFJ SYSTEM PRIVATE + "${pslp_SOURCE_DIR}/include" + "${dejavu_SOURCE_DIR}" + ) + endif () option(BUILD_LP_BENCHMARKS "Build LP benchmarks" OFF) diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 467aa7fce3..787f57bec5 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -78,6 +78,7 @@ #define CUOPT_MIP_STRONG_CHVATAL_GOMORY_CUTS "mip_strong_chvatal_gomory_cuts" #define CUOPT_MIP_REDUCED_COST_STRENGTHENING "mip_reduced_cost_strengthening" #define CUOPT_MIP_RINS "mip_rins" +#define CUOPT_MIP_RENS "mip_rens" #define CUOPT_MIP_OBJECTIVE_STEP "mip_objective_step" #define CUOPT_MIP_CUT_CHANGE_THRESHOLD "mip_cut_change_threshold" #define CUOPT_MIP_CUT_MIN_ORTHOGONALITY "mip_cut_min_orthogonality" @@ -137,14 +138,19 @@ #define CUOPT_MIP_HYPER_DIVING_SHOW_TYPE "mip_hyper_diving_show_type" /* @brief Recursive sub-MIP (RINS) hyper-parameters */ -#define CUOPT_MIP_HYPER_SUBMIP_BASE_TARGET_FIXRATE "mip_hyper_submip_base_target_fixrate" -#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE "mip_hyper_submip_min_fixrate" -#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP "mip_hyper_submip_min_fixrate_cap" -#define CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP "mip_hyper_submip_target_mip_gap" -#define CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_BASE "mip_hyper_submip_node_limit_base" -#define CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL "mip_hyper_submip_max_level" -#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" -#define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" +#define CUOPT_MIP_HYPER_SUBMIP_BASE_TARGET_FIXRATE "mip_hyper_submip_base_target_fixrate" +#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE "mip_hyper_submip_min_fixrate" +#define CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP "mip_hyper_submip_min_fixrate_cap" +#define CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP "mip_hyper_submip_target_mip_gap" +#define CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_OFFSET "mip_hyper_submip_node_limit_offset" +#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_OFFSET "mip_hyper_submip_iteration_limit_offset" +#define CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL "mip_hyper_submip_max_level" +#define CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO "mip_hyper_submip_iteration_limit_ratio" +#define CUOPT_MIP_HYPER_SUBMIP_ROUND_CLOSE_RATIO "mip_hyper_submip_round_close_ratio" +#define CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ "mip_hyper_submip_enable_cpufj" + +/* @brief Block bounded-variable-elimination step of cuOpt's internal MIP presolve */ +#define CUOPT_MIP_HYPER_BLOCK_BVE "mip_hyper_block_bve" /* @brief QCQP (barrier) scaling hyper-parameters */ #define CUOPT_QCQP_HYPER_RUIZ_EQUILIBRATION "qcqp_hyper_ruiz_equilibration" @@ -254,6 +260,8 @@ #define CUOPT_ATTR_IS_MIP 8 #define CUOPT_ATTR_HAS_QUADRATIC_OBJECTIVE 9 #define CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS 10 +#define CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS 11 +#define CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS 12 /* @brief Numeric/char array problem attribute selectors * (see cuOptGetProblem{Float,Char}ArrayAttribute; sized by num_variables / num_constraints). diff --git a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp index 28aa91a82f..f0673a4f66 100644 --- a/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp +++ b/cpp/include/cuopt/mathematical_optimization/cpu_optimization_problem.hpp @@ -123,6 +123,12 @@ class cpu_optimization_problem_t : public optimization_problem_interface_t& get_variable_names() const override; const std::vector& get_row_names() const override; const std::vector& get_quadratic_objective_offsets() const override; @@ -208,6 +214,7 @@ class cpu_optimization_problem_t : public optimization_problem_interface_t struct mip_submip_hyper_params_t { - // Enable or disable (recursive) RINS: -1 automatic, 0 disabled, 1 enabled + // Enable or disable (recursive) RINS/RENS: -1 automatic, 0 disabled, 1 enabled i_t rins = -1; + i_t rens = -1; // Base for calculating the target fix rate for the neighbourhood. Actual target value is // determined automatically according to the success and infeasible rate. @@ -29,7 +30,10 @@ struct mip_submip_hyper_params_t { f_t target_mip_gap = 0.01; // The base node limit for the sub-MIP - i_t node_limit_base = 200; + i_t node_limit_offset = 200; + + // The base iteration limit for the sub-MIP + i_t iteration_limit_offset = 10000; // The current level in the recursion. This is an internal parameter and will set automatically. i_t level = 0; @@ -41,6 +45,15 @@ struct mip_submip_hyper_params_t { // number of simplex iteration from the parent B&B. f_t iteration_limit_ratio = 0.8; + // If there is not enough variables fixed or we already found an improving solution, + // perform a short DFS to quickly find a feasible solution. This setting controls + // the maximum number of nodes allow for backtracking. + i_t dfs_max_backtrack = 5; + + // How many variables a single round can fix. Set in terms of ratio of + // (1 - current fixrate). + f_t round_close_ratio = 0.8; + // Run CPU FJ over the sub-MIP bool enable_cpufj = true; }; diff --git a/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp b/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp index bdfc2ffbd4..cc0b344104 100644 --- a/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp +++ b/cpp/include/cuopt/mathematical_optimization/optimization_problem.hpp @@ -284,6 +284,12 @@ class optimization_problem_t : public optimization_problem_interface_t std::string get_objective_name() const override; std::string get_problem_name() const override; problem_category_t get_problem_category() const override; + /** + * @brief Whether any variable type is SEMI_CONTINUOUS. + * + * Cached in set_variable_types(); used to skip SC reformulation host probes. + */ + bool has_semi_continuous_variables() const noexcept; const std::vector& get_variable_names() const override; const std::vector& get_row_names() const override; const std::vector& get_quadratic_objective_offsets() const override; @@ -391,6 +397,7 @@ class optimization_problem_t : public optimization_problem_interface_t rmm::cuda_stream_view stream_view_; problem_category_t problem_category_ = problem_category_t::LP; + bool has_semi_continuous_variables_{false}; bool maximize_{false}; i_t n_vars_{0}; i_t n_constraints_{0}; diff --git a/cpp/include/cuopt/routing/solver_settings.hpp b/cpp/include/cuopt/routing/solver_settings.hpp index 3aae7ff0ef..b68e774375 100644 --- a/cpp/include/cuopt/routing/solver_settings.hpp +++ b/cpp/include/cuopt/routing/solver_settings.hpp @@ -64,12 +64,27 @@ class solver_settings_t { */ void dump_best_results(const std::string& file_path, i_t interval); + /** + * @brief Set the random seed used by the routing solver. + * + * Controls the initial seed for random number generation. Use -1 to derive the seed + * from the problem, which is the default and reproduces a given problem run to run. + * + * @param[in] seed The seed, or -1 to derive it from the problem + */ + void set_seed(i_t seed); + /** * @brief Return set solving time * @return Solving time set in seconds */ f_t get_time_limit() const noexcept; + /** + * @brief Return the random seed, or -1 if it is derived from the problem + */ + i_t get_seed() const noexcept; + /** * @brief Return true if verbose mode is enabled */ @@ -93,6 +108,7 @@ class solver_settings_t { i_t dump_interval_{std::numeric_limits::max()}; bool dump_best_results_{false}; std::string best_result_file_name_; + i_t seed_{-1}; }; } // namespace CUOPT_EXPORT routing diff --git a/cpp/src/branch_and_bound/branch_and_bound.cpp b/cpp/src/branch_and_bound/branch_and_bound.cpp index 4dc6bc67a8..18b45afc19 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.cpp +++ b/cpp/src/branch_and_bound/branch_and_bound.cpp @@ -41,9 +41,17 @@ #include #include #include +#include #include #include +#define SUBMIP_VERBOSE false +#if SUBMIP_VERBOSE +#define DEBUG_SUBMIP(fmt, ...) settings_.log.print_format(fmt, __VA_ARGS__); +#else +#define DEBUG_SUBMIP(fmt, ...) +#endif + namespace cuopt::mathematical_optimization::mip { using simplex::basis_update_mpf_t; @@ -205,6 +213,7 @@ inline char feasible_solution_symbol(heuristics_origin_t origin) inline char feasible_solution_symbol(search_strategy_t strategy, bool show_diving) { if (strategy == search_strategy_t::BEST_FIRST) return 'B'; + if (strategy == search_strategy_t::RINS || strategy == search_strategy_t::RENS) return 'S'; if (!show_diving) return 'D'; switch (strategy) { @@ -215,8 +224,11 @@ inline char feasible_solution_symbol(search_strategy_t strategy, bool show_divin case search_strategy_t::GUIDED_DIVING: return 'G'; case search_strategy_t::FARKAS_DIVING: return 'F'; case search_strategy_t::VECTOR_LENGTH_DIVING: return 'V'; - default: return 'U'; + case search_strategy_t::RINS: return 'S'; + case search_strategy_t::RENS: return 'S'; } + + return 'U'; } template @@ -654,21 +666,30 @@ void branch_and_bound_t::set_solution_from_cpu_fj(f_t obj, // user space. template void branch_and_bound_t::set_solution_from_submip( + const lp_problem_t& lp, const std::vector& solution, const third_party_presolve_t& presolver, + submip_stats_t& submip_stats, f_t fixrate, - f_t obj) + [[maybe_unused]] std::string_view log_prefix) { + bool check_postsolve = false; std::vector leaf_sol; - presolver.uncrush_primal_solution(solution, leaf_sol); + presolver.uncrush_primal_solution(solution, leaf_sol, check_postsolve); + f_t obj = compute_objective(lp, leaf_sol); + std::vector user_sol; mutex_original_lp_.lock(); - uncrush_primal_solution(original_problem_, original_lp_, leaf_sol, user_sol); + uncrush_primal_solution(original_problem_, lp, leaf_sol, user_sol); mutex_original_lp_.unlock(); - settings_.log.debug_format("SubMIP found a feasible solution with obj={:.4g}", obj); + + DEBUG_SUBMIP("{}Sub-MIP found a feasible solution with obj={:.4g}", + log_prefix, + compute_user_objective(lp, obj)); + bool success = set_solution_from_heuristics(user_sol, heuristics_origin_t::SUBMIP); if (success) { - rins_stats_.save_success(fixrate); + submip_stats.save_success(fixrate); if (settings_.solution_callback != nullptr) { settings_.solution_callback(user_sol, obj); } } } @@ -991,7 +1012,7 @@ branch_variable_t branch_and_bound_t::variable_selection( branch_var = pc_.variable_selection(fractional, solution); } - round_dir = martin_criteria(solution[branch_var], root_relax_soln_.x[branch_var]); + round_dir = martin_criteria(solution[branch_var], worker->root_solution[branch_var]); return {branch_var, round_dir}; @@ -1000,10 +1021,10 @@ branch_variable_t branch_and_bound_t::variable_selection( original_lp_, fractional, solution, var_up_locks_, var_down_locks_, log); case search_strategy_t::LINE_SEARCH_DIVING: - return line_search_diving(fractional, solution, root_relax_soln_.x, log); + return line_search_diving(fractional, solution, worker->root_solution, log); case search_strategy_t::PSEUDOCOST_DIVING: - return pseudocost_diving(pc_, fractional, solution, root_relax_soln_.x, log); + return pseudocost_diving(pc_, fractional, solution, worker->root_solution, log); case search_strategy_t::GUIDED_DIVING: assert(incumbent_.has_incumbent); @@ -1018,9 +1039,10 @@ branch_variable_t branch_and_bound_t::variable_selection( case search_strategy_t::VECTOR_LENGTH_DIVING: return vector_length_diving(worker->leaf_problem, fractional, solution, log); - case search_strategy_t::SUBMIP: // This is used for solving the DFS of the sub-MIP. + case search_strategy_t::RINS: // This is used for solving the DFS of the sub-MIP. + case search_strategy_t::RENS: branch_var = pc_.variable_selection(fractional, solution); - round_dir = martin_criteria(solution[branch_var], root_relax_soln_.x[branch_var]); + round_dir = martin_criteria(solution[branch_var], worker->root_solution[branch_var]); return {branch_var, round_dir}; } @@ -1173,7 +1195,7 @@ struct deterministic_bfs_policy_t const std::vector& x) override { i_t var = this->worker.pc_snapshot.variable_selection(fractional, x); - auto dir = martin_criteria(x[var], this->bnb.root_relax_soln_.x[var]); + auto dir = martin_criteria(x[var], this->worker.root_solution[var]); return {var, dir}; } @@ -1255,15 +1277,15 @@ struct deterministic_diving_policy_t switch (this->worker.diving_type) { case search_strategy_t::PSEUDOCOST_DIVING: return pseudocost_diving( - this->worker.pc_snapshot, fractional, x, *this->worker.root_solution, log); + this->worker.pc_snapshot, fractional, x, this->worker.root_solution, log); case search_strategy_t::LINE_SEARCH_DIVING: - return line_search_diving(fractional, x, *this->worker.root_solution, log); + return line_search_diving(fractional, x, this->worker.root_solution, log); case search_strategy_t::GUIDED_DIVING: if (this->worker.incumbent_snapshot.empty()) { return pseudocost_diving( - this->worker.pc_snapshot, fractional, x, *this->worker.root_solution, log); + this->worker.pc_snapshot, fractional, x, this->worker.root_solution, log); } else { return guided_diving( this->worker.pc_snapshot, fractional, x, this->worker.incumbent_snapshot, log); @@ -1565,8 +1587,9 @@ dual_status_t branch_and_bound_t::solve_node_lp( } else { lp_settings.cut_off = cutoff + settings_.dual_tol; } - lp_settings.inside_mip = 2; - lp_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + lp_settings.inside_mip = 2; + lp_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + if (lp_settings.time_limit <= 0.0) { return dual_status_t::TIME_LIMIT; } lp_settings.scale_columns = false; lp_settings.iteration_limit = iter_limit; @@ -1594,7 +1617,7 @@ dual_status_t branch_and_bound_t::solve_node_lp( bool feasible = worker->set_lp_variable_bounds(node_ptr, settings_); dual_status_t lp_status = dual_status_t::DUAL_UNBOUNDED; - worker->leaf_edge_norms = edge_norms_; + worker->leaf_edge_norms = worker->root_edge_norm; if (worker->recompute_bounds && worker->orbital_fixing && worker->search_strategy == search_strategy_t::BEST_FIRST) { worker->orbital_fixing->reset(symmetry_, node_ptr); @@ -1672,7 +1695,7 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, f_t rel_gap = user_relative_gap(user_obj, user_lower); f_t abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound); - bool can_launch_rins = true; + bool can_launch_new_submip = true; while (stack.size() > 0 && (solver_status_ == mip_status_t::UNSET && is_running_) && rel_gap > settings_.relative_mip_gap_tol && abs_gap > settings_.absolute_mip_gap_tol) { @@ -1782,7 +1805,9 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, worker->recompute_bounds = node_status != node_status_t::HAS_CHILDREN; if (node_status == node_status_t::HAS_CHILDREN) { - if (can_launch_rins) { can_launch_rins = !launch_rins_worker(worker->leaf_solution.x); } + if (can_launch_new_submip) { + can_launch_new_submip = !launch_submip_worker(worker->leaf_solution.x); + } // The stack should only contain the children of the current parent. // If the stack size is greater than 0, @@ -1823,6 +1848,10 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, abs_gap = compute_user_abs_gap(original_lp_, upper_bound, lower_bound); } + if (solver_status_ == mip_status_t::TIME_LIMIT || solver_status_ == mip_status_t::OPTIMAL) { + node_concurrent_halt_ = 1; + } + // If the solver exits early without consuming the local stack, or converged according to // the gap rules while nodes are still pending, put those nodes back into the global queue // before returning. @@ -1840,6 +1869,9 @@ void branch_and_bound_t::plunge_with(bfs_worker_t* worker, template void branch_and_bound_t::launch_bfs_worker(bfs_worker_t* worker) { + // The status may change after the caller checks its search-loop condition. + if (solver_status_ != mip_status_t::UNSET) { return; } + bfs_worker_t* idle_worker = bfs_worker_pool_.pop_idle_worker(); if (!idle_worker) return; @@ -1976,8 +2008,7 @@ void branch_and_bound_t::best_first_search_with(bfs_worker_t rel_gap = user_relative_gap(user_obj, user_lower); if (abs_gap <= settings_.absolute_mip_gap_tol || rel_gap <= settings_.relative_mip_gap_tol) { - node_concurrent_halt_ = 1; - solver_status_ = mip_status_t::OPTIMAL; + solver_status_ = mip_status_t::OPTIMAL; break; } @@ -1987,6 +2018,10 @@ void branch_and_bound_t::best_first_search_with(bfs_worker_t } } + if (solver_status_ == mip_status_t::TIME_LIMIT || solver_status_ == mip_status_t::OPTIMAL) { + node_concurrent_halt_ = 1; + } + // If the worker has still nodes in the queue (this can happen if it was stopped due to // time limit, small gap or other reason), then do not add back to the pool to avoid // constantly trying to start it again @@ -2044,7 +2079,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, } if (toc(exploration_stats_.start_time) > settings_.time_limit) { - solver_status_ = mip_status_t::TIME_LIMIT; + node_concurrent_halt_ = 1; + solver_status_ = mip_status_t::TIME_LIMIT; break; } if (dive_stats.nodes_explored >= diving_node_limit) { break; } @@ -2063,7 +2099,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, ++dive_stats.nodes_explored; if (lp_status == dual_status_t::TIME_LIMIT) { - solver_status_ = mip_status_t::TIME_LIMIT; + node_concurrent_halt_ = 1; + solver_status_ = mip_status_t::TIME_LIMIT; break; } if (lp_status == dual_status_t::CONCURRENT_LIMIT) { break; } @@ -2100,7 +2137,8 @@ void branch_and_bound_t::dive_with(diving_worker_t* worker, // This is called from the RINS method which already handle the return to the // pool part. Besides, they do not share the same pool. - if (worker->search_strategy != search_strategy_t::SUBMIP) { + if (worker->search_strategy != search_strategy_t::RINS && + worker->search_strategy != search_strategy_t::RENS) { diving_worker_pool_.return_worker_to_pool(worker); } } @@ -2158,25 +2196,38 @@ bool branch_and_bound_t::launch_diving_worker(bfs_worker_t* } template -bool branch_and_bound_t::launch_rins_worker(const std::vector& sol) +bool branch_and_bound_t::launch_submip_worker(const std::vector& sol) { - if (settings_.submip_settings.rins == 0) return false; - if (!incumbent_.has_incumbent) return false; - if (rins_worker_pool_.num_idle() == 0) return false; + if (settings_.submip_settings.rins == 0 && settings_.submip_settings.rens == 0) return false; + if (settings_.submip_settings.rens == 0 && !incumbent_.has_incumbent) return false; + if (submip_worker_pool_.num_idle() == 0) return false; - diving_worker_t* worker = rins_worker_pool_.pop_idle_worker(); + diving_worker_t* worker = submip_worker_pool_.pop_idle_worker(); if (!worker) return false; + std::vector current_incumbent; + mutex_upper_.lock(); + bool use_rins = incumbent_.has_incumbent && settings_.submip_settings.rins != 0; + if (use_rins) current_incumbent = incumbent_.x; + mutex_upper_.unlock(); + + // Note that this node does not have the vstatus (it was cleared at the start of B&B exploration) + worker->start_node = mip_node_t(root_objective_, root_vstatus_); + worker->leaf_vstatus = root_vstatus_; + worker->leaf_problem.lower = original_lp_.lower; + worker->leaf_problem.upper = original_lp_.upper; + worker->leaf_solution.x = sol; + worker->search_strategy = use_rins ? search_strategy_t::RINS : search_strategy_t::RENS; worker->set_active(); - worker->search_strategy = search_strategy_t::SUBMIP; if (settings_.inside_submip) { // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy // function for included tasks. - rins(worker, sol); + recursive_submip(worker, current_incumbent, var_types_); } else { -#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) firstprivate(worker, sol) - rins(worker, sol); +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ + firstprivate(worker, current_incumbent) + recursive_submip(worker, current_incumbent, var_types_); } return true; @@ -2185,29 +2236,20 @@ bool branch_and_bound_t::launch_rins_worker(const std::vector& so template void branch_and_bound_t::solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, - i_t num_var_fixed, - i_t num_integers, - i_t submip_level, - std::string_view log_prefix) + const std::vector& var_types, + submip_stats_t& submip_stats, + f_t fixrate, + i_t simplex_iter_used, + bool is_root_heuristic) { double start_time = tic(); - std::vector& lower = worker->leaf_problem.lower; - std::vector& upper = worker->leaf_problem.upper; - std::vector& bounds_changed = worker->bounds_changed; - f_t fixrate = (f_t)num_var_fixed / num_integers; - - bool feasible = - worker->node_presolver.bounds_strengthening(settings_, bounds_changed, lower, upper); - - if (!feasible) { - // This should never happen since we are fixing bounds that are already in the incumbent. - rins_stats_.save_infeasible(fixrate); - return; - } + i_t submip_level = settings_.submip_settings.level + 1; + std::string log_prefix = + std::format("[{} {}] ", search_strategy_to_string(worker->search_strategy), submip_level); - f_t user_lower = compute_user_objective(original_lp_, get_lower_bound()); - f_t user_obj = compute_user_objective(original_lp_, upper_bound_.load()); + f_t user_lower = compute_user_objective(worker->leaf_problem, get_lower_bound()); + f_t user_obj = compute_user_objective(worker->leaf_problem, upper_bound_.load()); f_t rel_gap = user_relative_gap(user_obj, user_lower); i_t explored = exploration_stats_.nodes_explored; @@ -2220,10 +2262,10 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.inside_submip = 1; submip_settings.strong_branching_simplex_iteration_limit = 50; submip_settings.submip_settings.level = submip_level; - submip_settings.log.log = false; submip_settings.benchmark_info_ptr = nullptr; + submip_settings.log.log = SUBMIP_VERBOSE; -#ifdef DEBUG_SUBMIP +#ifdef SAVE_SUBMIP_TO_FILE submip_settings.log.log_prefix = std::format("{}{}", settings_.log.log_prefix, worker->worker_id); CUOPT_LOG_INFO("Writting submip %s to MPS file", submip_settings.log.log_prefix); worker->leaf_problem.write_mps(std::format("submip-{}.mps", submip_settings.log.log_prefix), @@ -2232,21 +2274,34 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke submip_settings.log.log_prefix = log_prefix; #endif - submip_settings.node_limit = settings_.submip_settings.node_limit_base + explored / 20; + submip_settings.node_limit = settings_.submip_settings.node_limit_offset + explored / 20; + + // Add offset only on the top call, we want number of simplex iteration to decay + // as we go down the recursion to avoid spending too much time in the deeper levels. + int64_t iter_offset = + settings_.inside_submip ? 0 : settings_.submip_settings.iteration_limit_offset; + int64_t simplex_iter = exploration_stats_.total_simplex_iters; + f_t iter_ratio = settings_.submip_settings.iteration_limit_ratio; + submip_settings.branch_and_bound_simplex_iteration_limit = - exploration_stats_.total_simplex_iters * settings_.submip_settings.iteration_limit_ratio; + iter_offset + simplex_iter * iter_ratio - simplex_iter_used; + if (submip_settings.branch_and_bound_simplex_iteration_limit <= 0) { return; } + submip_settings.time_limit = settings_.time_limit - toc(exploration_stats_.start_time); - if (submip_settings.time_limit < 0) { return; } + if (submip_settings.time_limit <= 0) { return; } submip_settings.relative_mip_gap_tol = std::min(settings_.submip_settings.target_mip_gap, rel_gap); - submip_settings.submip_settings.rins = - settings_.submip_settings.rins != 0 && submip_level <= settings_.submip_settings.max_level; + bool max_recursion = submip_level > settings_.submip_settings.max_level; + submip_settings.submip_settings.rins = settings_.submip_settings.rins != 0 && !max_recursion; + submip_settings.submip_settings.rens = settings_.submip_settings.rens != 0 && !max_recursion; - submip_settings.log.debug_format( - "Sub-MIP solve settings: time_limit={:.2f}, node_limit={}, iter_limit={} (current_iter={}), " + DEBUG_SUBMIP("{}Sub-MIP: fixrate={:.2f}", log_prefix, fixrate) + DEBUG_SUBMIP( + "{}Sub-MIP solve settings: time_limit={:.2f}, node_limit={}, iter_limit={} (current_iter={}), " "tol={:g}", + log_prefix, submip_settings.time_limit, submip_settings.node_limit, submip_settings.branch_and_bound_simplex_iteration_limit, @@ -2257,7 +2312,7 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke // there is only equality rows (the range row vector is empty) and it contains // structural + slacks + cuts constraints/variables. user_problem_t submip_problem(original_problem_.handle_ptr); - simplex::convert_lp_to_user_problem(worker->leaf_problem, var_types_, settings_, submip_problem); + simplex::convert_lp_to_user_problem(worker->leaf_problem, var_types, settings_, submip_problem); third_party_presolve_t presolver; f_t presolve_time_limit = std::min(0.1 * submip_settings.time_limit, 60.0); @@ -2269,86 +2324,91 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke if (presolver_status == third_party_presolve_status_t::INFEASIBLE || presolver_status == third_party_presolve_status_t::UNBNDORINFEAS || presolver_status == third_party_presolve_status_t::UNBOUNDED) { - rins_stats_.save_infeasible(fixrate); + DEBUG_SUBMIP("{}Presolve detected infeasibility", log_prefix); + submip_stats.save_infeasible(fixrate); return; } // Also handle optimal if (submip_problem.num_rows == 0 || submip_problem.num_cols == 0) { - submip_settings.log.debug_format( - "Sub-MIP presolved to a trivial {} x {} problem; solving by bound pushing", - submip_problem.num_rows, - submip_problem.num_cols); - - std::vector reduced_sol(submip_problem.num_cols); - f_t obj = 0.0; - - for (i_t j = 0; j < submip_problem.num_cols; ++j) { - const f_t c = submip_problem.objective[j]; - const f_t l = submip_problem.lower[j]; - const f_t u = submip_problem.upper[j]; - // Minimize c_j x_j over [l, u]; fall back to any finite bound (0 if both are infinite). - if (c < -settings_.zero_tol) { - reduced_sol[j] = std::isfinite(u) ? u : (std::isfinite(l) ? l : 0); - } else { - reduced_sol[j] = std::isfinite(l) ? l : (std::isfinite(u) ? u : 0); - } - - obj += reduced_sol[j] * c; - } + DEBUG_SUBMIP("{}Reduced to a trivial {} x {} problem; solving by bound pushing", + log_prefix, + submip_problem.num_rows, + submip_problem.num_cols); + submip_stats.save_empty(); + return; + } - set_solution_from_submip(reduced_sol, presolver, fixrate, obj); + if (toc(exploration_stats_.start_time) > settings_.time_limit) { + solver_status_ = mip_status_t::TIME_LIMIT; return; } submip_settings.heuristic_preemption_callback = nullptr; submip_settings.dual_simplex_objective_callback = nullptr; submip_settings.set_simplex_solution_callback = nullptr; - submip_settings.solution_callback = [this, &presolver, fixrate](const std::vector& solution, - f_t obj) { - this->set_solution_from_submip(solution, presolver, fixrate, obj); - }; + submip_settings.solution_callback = + [this, &presolver, fixrate, &submip_stats, log_prefix, worker](const std::vector& solution, + f_t obj) { + this->set_solution_from_submip( + worker->leaf_problem, solution, presolver, submip_stats, fixrate, log_prefix); + }; - submip_settings.log.debug_format("Sub-MIP: {} constraints, {} variables, {} nonzeros\n", - submip_problem.num_rows, - submip_problem.num_cols, - submip_problem.A.nnz()); + DEBUG_SUBMIP("{}Sub-MIP: {} constraints, {} variables, {} nonzeros\n", + log_prefix, + submip_problem.num_rows, + submip_problem.num_cols, + submip_problem.A.nnz()); probing_implied_bound_t empty_probing(submip_problem.num_cols); branch_and_bound_t submip_bnb(submip_problem, submip_settings, tic(), empty_probing); mip_solution_t submip_solution(submip_problem.num_cols); - // Crush the incumbent to presolve space. It may not be valid for the sub-MIP since we - // may fix integer variables that does not match the current incumbent to reach the target - // fix rate. std::vector presolved_incumbent; - presolver.crush_primal_solution(submip_problem, current_incumbent, presolved_incumbent); - submip_bnb.set_initial_guess(presolved_incumbent); - const f_t user_upper = compute_user_objective(original_lp_, upper_bound_.load()); - const f_t submip_cutoff = - user_upper / submip_bnb.original_lp_.obj_scale - submip_bnb.original_lp_.obj_constant; - submip_bnb.set_initial_upper_bound(submip_cutoff); + // We do not have an incumbent yet, so skip the initial guess. + if (!current_incumbent.empty()) { + // Crush the incumbent to presolve space. It may not be valid for the sub-MIP since we + // may fix integer variables that does not match the current incumbent to reach the target + // fix rate. + presolver.crush_primal_solution(submip_problem, current_incumbent, presolved_incumbent); + submip_bnb.set_initial_guess(presolved_incumbent); + } + + // Even if we do not have a valid incumbent now, the upper bound can still be set by the early + // heuristics. + if (std::isfinite(upper_bound_.load())) { + const f_t user_upper = compute_user_objective(worker->leaf_problem, upper_bound_.load()); + const f_t submip_cutoff = compute_presolved_objective(submip_bnb.original_lp_, user_upper); + submip_bnb.set_initial_upper_bound(submip_cutoff); + } - submip_bnb.set_initial_pseudocost(pc_, presolver.get_reduced_to_original_map()); + if (!is_root_heuristic) + submip_bnb.set_initial_pseudocost(pc_, presolver.get_reduced_to_original_map()); if (submip_halt_callback_) { // Copy the halt callback to the deeper level. submip_bnb.set_submip_halt_callback(submip_halt_callback_); } else { // This should only be called by the main solver. - submip_bnb.set_submip_halt_callback([this](f_t, f_t submip_lower_bound) { + submip_bnb.set_submip_halt_callback([this, worker](f_t, f_t submip_lower_bound) { f_t user_upper = compute_user_objective(this->original_lp_, this->upper_bound_.load()); bool is_cutoff = original_lp_.obj_scale > 0 ? submip_lower_bound > user_upper : user_upper > submip_lower_bound; bool is_solver_running = this->solver_status_ == mip_status_t::UNSET && this->is_running_; - return is_cutoff || !is_solver_running; + return is_cutoff || !is_solver_running || worker->halt; }); } fj_cpu_worker_t submip_fj_cpu_worker; if (settings_.submip_settings.enable_cpufj) { + // Since we do not have an incumbent, use the LP solution of the last round of variable fixing + // in RENS. + if (worker->search_strategy == search_strategy_t::RENS) { + presolver.crush_primal_solution(submip_problem, worker->leaf_solution.x, presolved_incumbent); + } + // Launch a CPU FJ worker on the presolved sub-MIP with a fixed budget (in terms of work units) // to run in parallel with the cut-and-branch algorithm with the goal of finding a quick // feasible solution for the sub-MIP problem. The CPU FJ uses the current incumbent (crushed @@ -2370,6 +2430,7 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke f_t work_limit = 1.0; submip_fj_cpu_worker.create_worker(submip_bnb.original_lp_, submip_bnb.var_types_, + submip_bnb.original_problem_.num_cols, initial_guess, submip_bnb.settings_, std::format("{} [CPU FJ]", log_prefix), @@ -2380,8 +2441,9 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke mip_status_t submip_status = submip_bnb.solve(submip_solution); f_t submip_time = toc(start_time); - submip_settings.log.debug_format( - "Sub-MIP: status={}, iterations={} (total={}), presolve_time={:.2f}, total_time={:.2f} \n", + DEBUG_SUBMIP( + "{}Sub-MIP: status={}, iterations={} (total={}), presolve_time={:.2f}, total_time={:.2f} \n", + log_prefix, mip_status_to_string(submip_status), submip_solution.simplex_iterations, exploration_stats_.total_simplex_iters.load(), @@ -2390,12 +2452,13 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke if (submip_status == mip_status_t::NUMERICAL) { return; } if (submip_status == mip_status_t::INFEASIBLE || submip_status == mip_status_t::UNBOUNDED) { - rins_stats_.save_infeasible(fixrate); + submip_stats.save_infeasible(fixrate); return; } if (submip_solution.has_incumbent) { - set_solution_from_submip(submip_solution.x, presolver, fixrate, submip_solution.objective); + set_solution_from_submip( + worker->leaf_problem, submip_solution.x, presolver, submip_stats, fixrate, log_prefix); } // Accumulate simplex iterations to determine when to stop exploring the sub-MIP @@ -2405,9 +2468,9 @@ void branch_and_bound_t::solve_submip(diving_worker_t* worke } template -inline f_t submip_get_max_fixrate(const submip_stats_t& stats, - const mip_submip_hyper_params_t& submip_settings, - pcgenerator_t& rng) +f_t submip_get_max_fixrate(const submip_stats_t& stats, + const mip_submip_hyper_params_t& submip_settings, + pcgenerator_t& rng) { // Adaptive fix rate based on previous successes and failures. f_t low = submip_settings.base_target_fixrate; @@ -2441,6 +2504,8 @@ void get_unfixed_integer_variables(const std::vector& lower, if (std::abs(lower[j] - upper[j]) <= fixed_tol) { continue; } integer_list.push_back(j); } + + assert(!integer_list.empty() && "The integer list cannot be empty!"); } template @@ -2457,38 +2522,68 @@ void fix_variable(i_t j, } template -void apply_rins_fixings(const simplex_solver_settings_t& settings, - const std::vector& current_sol, - const std::vector& fractional, - const std::vector& current_incumbent, - i_t max_var_fixed, - std::vector& lower, - std::vector& upper, - std::vector& bounds_changed, - i_t& num_var_fixed) +i_t apply_rens_fixings(const simplex_solver_settings_t& settings, + const std::vector& node_solution, + const std::vector& integer_list, + i_t target_num_fixed, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) { - for (i_t j : fractional) { - if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) { continue; } + i_t num_fixed = 0; + i_t num_bound_changed = 0; + + for (i_t j : integer_list) { + if (num_fixed >= target_num_fixed) break; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) continue; + f_t old_lower = lower[j]; + f_t old_upper = upper[j]; + lower[j] = std::clamp(std::floor(node_solution[j]), old_lower, old_upper); + upper[j] = std::clamp(std::ceil(node_solution[j]), old_lower, old_upper); + bounds_changed[j] = lower[j] != old_lower || upper[j] != old_upper; + num_bound_changed += bounds_changed[j]; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) ++num_fixed; + } + + return num_bound_changed; +} + +template +i_t apply_rins_fixings(const simplex_solver_settings_t& settings, + const std::vector& current_sol, + const std::vector& integer_list, + const std::vector& current_incumbent, + f_t target_fixrate, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) +{ + i_t num_fixed = 0; + i_t target_num_fixed = target_fixrate * integer_list.size(); + + for (i_t j : integer_list) { + if (num_fixed >= target_num_fixed) break; + if (std::abs(lower[j] - upper[j]) <= settings.fixed_tol) continue; if (std::abs(current_sol[j] - current_incumbent[j]) <= settings.integer_tol) { f_t fixed_val = std::round(current_sol[j]); fix_variable(j, lower, upper, bounds_changed, fixed_val); - ++num_var_fixed; - if (num_var_fixed >= max_var_fixed) break; + ++num_fixed; } } + + return num_fixed; } template -void extend_variable_fixings(const simplex_solver_settings_t& settings, - const std::vector& obj_coeffs, - const std::vector& fractional, - const std::vector& current_sol, - const std::vector& root_solution, - i_t max_var_fixed, - std::vector& lower, - std::vector& upper, - std::vector& bounds_changed, - i_t& num_var_fixed) +i_t extend_variable_fixings(const simplex_solver_settings_t& settings, + const std::vector& obj_coeffs, + const std::vector& fractional, + const std::vector& current_sol, + const std::vector& root_solution, + i_t target_num_fixed, + std::vector& lower, + std::vector& upper, + std::vector& bounds_changed) { std::vector> candidates; for (i_t j : fractional) { @@ -2517,157 +2612,159 @@ void extend_variable_fixings(const simplex_solver_settings_t& settings return std::get<0>(a) < std::get<0>(b); }); - f_t change = 0; + i_t num_fixed = 0; + f_t change = 0; + for (auto [dist, j, fixed_val] : candidates) { + if (num_fixed >= target_num_fixed) break; + fix_variable(j, lower, upper, bounds_changed, fixed_val); - ++num_var_fixed; - if (num_var_fixed >= max_var_fixed) break; + ++num_fixed; // Limit the amount of fixing to the current LP. change += dist; - if (change >= 0.5) { break; } + if (change >= 0.5) break; } + + return num_fixed; } template -void branch_and_bound_t::rins(diving_worker_t* rins_worker, - const std::vector& node_solution) +f_t calculate_fixrate(const std::vector& integer_list, + const std::vector& lower, + const std::vector& upper, + f_t fixed_tol) { - raft::common::nvtx::range scope("BB::rins_thread"); - if (rins_worker->orbital_fixing) { rins_worker->orbital_fixing->disable(); } + i_t num_fixed = 0; + for (i_t j : integer_list) { + if (std::abs(lower[j] - upper[j]) <= fixed_tol) ++num_fixed; + } - i_t submip_level = settings_.submip_settings.level + 1; - std::string log_prefix = std::format("[RINS {}] ", submip_level); + return (f_t)num_fixed / integer_list.size(); +} - ++rins_stats_.total_calls; +template +void branch_and_bound_t::recursive_submip(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic) +{ + raft::common::nvtx::range scope("BB::submip_thread"); + if (worker->orbital_fixing) { worker->orbital_fixing->disable(); } - bool has_submip = false; - const f_t abs_fathom_tol = settings_.absolute_mip_gap_tol / 10; - - branch_and_bound_stats_t rins_stats; - - // Note that this node does not have the vstatus (it was clear at the start of B&B exploration) - mip_node_t node = search_tree_.root.detach_copy(); - rins_worker->leaf_vstatus = root_vstatus_; - rins_worker->leaf_problem.lower = original_lp_.lower; - rins_worker->leaf_problem.upper = original_lp_.upper; - rins_worker->leaf_solution.x = node_solution; - rins_worker->recompute_bounds = false; - rins_worker->recompute_basis = true; - - std::vector& lower = rins_worker->leaf_problem.lower; - std::vector& upper = rins_worker->leaf_problem.upper; - std::vector& bounds_changed = rins_worker->bounds_changed; - std::vector& current_sol = rins_worker->leaf_solution.x; - std::vector fractional; - i_t num_frac = fractional_variables(settings_, current_sol, var_types_, fractional); + i_t submip_level = settings_.submip_settings.level + 1; + std::string log_prefix = + std::format("[{} {}] ", search_strategy_to_string(worker->search_strategy), submip_level); - std::vector current_incumbent; - mutex_upper_.lock(); - current_incumbent = incumbent_.x; - mutex_upper_.unlock(); + assert((worker->search_strategy == search_strategy_t::RINS || + worker->search_strategy == search_strategy_t::RENS) && + "Sub-MIP worker must be set to RINS or RENS type"); - std::vector integer_list; - get_unfixed_integer_variables(lower, upper, var_types_, settings_.fixed_tol, integer_list); + submip_stats_t& submip_stats = + worker->search_strategy == search_strategy_t::RINS ? rins_stats_ : rens_stats_; - i_t num_integers = integer_list.size(); + ++submip_stats.total_calls; - f_t max_fixrate = - submip_get_max_fixrate(rins_stats_, settings_.submip_settings, rins_worker->rng); - f_t min_fixrate = std::min(settings_.submip_settings.min_fixrate, max_fixrate); - i_t max_var_fixed = max_fixrate * num_integers; - i_t min_var_fixed = min_fixrate * num_integers; - i_t num_var_fixed = 0; - - while (solver_status_ == mip_status_t::UNSET && is_running_) { - // RINS neighbourhood 1: Fix all the integer variables where the starting solution matches the - // current incumbent, considering only the fractional values in the current node - i_t prev_num_fixed = num_var_fixed; - apply_rins_fixings(settings_, - current_sol, - fractional, - current_incumbent, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - // Enough variables has been fixed - if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; - break; - } + bool has_submip = false; + worker->recompute_bounds = false; + worker->recompute_basis = true; - if (toc(exploration_stats_.start_time) > settings_.time_limit) { - solver_status_ = mip_status_t::TIME_LIMIT; - break; - } + branch_and_bound_stats_t stats; + mip_node_t& node = worker->start_node; + std::vector& lower = worker->leaf_problem.lower; + std::vector& upper = worker->leaf_problem.upper; + std::vector& bounds_changed = worker->bounds_changed; + std::vector& current_sol = worker->leaf_solution.x; + + std::fill(bounds_changed.begin(), bounds_changed.end(), false); - if (prev_num_fixed == num_var_fixed) { - // RINS neighbourhood 2: Search the entire list of integer variables where the current - // LP solution matches the current incumbent. - apply_rins_fixings(settings_, - current_sol, - integer_list, - current_incumbent, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - // Enough variables were fixed - if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); + std::vector fractional; + i_t num_frac = fractional_variables(settings_, current_sol, var_types, fractional); + + std::vector integer_list; + get_unfixed_integer_variables(lower, upper, var_types, settings_.fixed_tol, integer_list); + + i_t num_integers = integer_list.size(); + f_t max_fixrate = submip_get_max_fixrate(submip_stats, settings_.submip_settings, worker->rng); + f_t min_fixrate = settings_.submip_settings.min_fixrate; + f_t fixrate = 0; + f_t close_ratio = settings_.submip_settings.round_close_ratio; + + i_t round = 0; + + while (solver_status_ == mip_status_t::UNSET && is_running_ && !worker->halt) { + f_t prev_fixrate = fixrate; + f_t distance = 1.0 - (1.0 - prev_fixrate) * close_ratio; + f_t round_target_fixrate = std::min(distance, max_fixrate) - prev_fixrate; + i_t round_target = round_target_fixrate * num_integers; + i_t num_bound_changed = 0; + // Shuffle the fractional and integer list, so every variable has the same chance to the picked + // (we iterate the list in order). + worker->rng.shuffle(integer_list); + worker->rng.shuffle(fractional); + if (worker->search_strategy == search_strategy_t::RINS) { + // RINS neighbourhood: Fix all the integer variables where the current solution matches the + // incumbent. We are using the `max_fixrate` here to allow RINS to fix all integer variables + // that it can within our budget. + num_bound_changed = apply_rins_fixings(settings_, + current_sol, + integer_list, + current_incumbent, + max_fixrate - prev_fixrate, + lower, + upper, + bounds_changed); + + // The RINS neighbourhood ran dry. If it is already tight enough, take it rather than + // diluting it with fixings that do not agree with the incumbent. + if (num_bound_changed == 0 && fixrate >= min_fixrate) { has_submip = true; break; } - // Even considering the entire integer list, we were unable to fix a single variable in this - // iteration. Iterate over the fractional variables again and fixing those that closest to - // an integer solution first in order to reach the fixing threshold. - if (prev_num_fixed == num_var_fixed) { - extend_variable_fixings(settings_, - rins_worker->leaf_problem.objective, - fractional, - current_sol, - root_relax_soln_.x, - max_var_fixed, - lower, - upper, - bounds_changed, - num_var_fixed); - - if (num_var_fixed >= min_var_fixed) { - settings_.log.debug_format("{}Fixed {} variables (max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; + } else if (worker->search_strategy == search_strategy_t::RENS) { + if (round_target == 0) { + round_target_fixrate = max_fixrate - prev_fixrate; + round_target = round_target_fixrate * num_integers; + if (round_target == 0) { + has_submip = fixrate > 0; break; } + } - if (prev_num_fixed == num_var_fixed) { - settings_.log.debug_format("{}Could not fix more variables ({}, max={}, min={})\n", - log_prefix, - num_var_fixed, - max_var_fixed, - min_var_fixed); - has_submip = true; + num_bound_changed = apply_rens_fixings( + settings_, current_sol, integer_list, round_target, lower, upper, bounds_changed); + } + + // Even considering the entire integer list, we were unable to fix a single variable in this + // iteration. Iterate over the fractional variables again and fixing those that closest to + // an integer solution first in order to reach the fixing threshold. + if (num_bound_changed == 0) { + if (round_target == 0) { + round_target_fixrate = max_fixrate - prev_fixrate; + round_target = round_target_fixrate * num_integers; + if (round_target == 0) { + has_submip = fixrate > 0; break; } } + + num_bound_changed = extend_variable_fixings(settings_, + worker->leaf_problem.objective, + fractional, + current_sol, + worker->root_solution, + round_target, + lower, + upper, + bounds_changed); + + // Even sweep over all integer variables, we exhausted all variables that can be fixed. + // If this is the case, then tries to solve the sub-mip anyway. + if (num_bound_changed == 0) { + has_submip = true; + break; + } } if (toc(exploration_stats_.start_time) > settings_.time_limit) { @@ -2675,49 +2772,112 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, break; } + bool is_feasible = + worker->node_presolver.bounds_strengthening(settings_, bounds_changed, lower, upper); + fixrate = calculate_fixrate(integer_list, lower, upper, settings_.fixed_tol); + + DEBUG_SUBMIP( + "{}Round {}: fixed {:.0f} ({:.2f}) -> {:.0f} ({:.2f}) variables. target round fixrate = {} " + "({:.2f}). " + "max fixrate = {:.4g}", + log_prefix, + round, + prev_fixrate * num_integers, + prev_fixrate, + fixrate * num_integers, + fixrate, + round_target, + round_target_fixrate, + max_fixrate); + + if (!is_feasible) { + DEBUG_SUBMIP("{}Round {}: bound strengthening detected infeasibility.", log_prefix, round) + break; + } + + if (fixrate >= max_fixrate) { + has_submip = true; + break; + } + // After fixing the variables, re-solve the LP relaxation. We use the optimal solution // in the next iteration to find additional variable fixings. // We continue to do this until enough variables were fixed or no variable is left to fix. logger_t log; - log.log = false; - dual_status_t lp_status = solve_node_lp(&node, rins_worker, rins_stats, log); + log.log = false; + + int64_t iter_offset = + settings_.inside_submip ? 0 : settings_.submip_settings.iteration_limit_offset; + int64_t simplex_iter = exploration_stats_.total_simplex_iters; + f_t iter_ratio = settings_.submip_settings.iteration_limit_ratio; + int64_t simplex_iter_limit = iter_offset + simplex_iter * iter_ratio; + i_t max_iter = std::min(simplex_iter_limit - stats.total_simplex_iters, + std::numeric_limits::max()); + if (max_iter <= 0) { + DEBUG_SUBMIP("{}Round {}: max iteration reached! {}/{}", + log_prefix, + round, + stats.total_simplex_iters.load(), + simplex_iter_limit) + break; + } - if (lp_status != dual_status_t::OPTIMAL) { break; } + dual_status_t lp_status = solve_node_lp(&node, worker, stats, log, max_iter); + if (lp_status != dual_status_t::OPTIMAL) { + DEBUG_SUBMIP("{}Round {}: simplex returned {}", + log_prefix, + round, + simplex::dual_status_to_string(lp_status)) + break; + } fractional.clear(); - num_frac = fractional_variables(settings_, current_sol, var_types_, fractional); + num_frac = fractional_variables(settings_, current_sol, var_types, fractional); - f_t leaf_obj = compute_objective(rins_worker->leaf_problem, current_sol); + f_t leaf_obj = compute_objective(worker->leaf_problem, current_sol); node.lower_bound = leaf_obj; snap_to_lattice(&node, leaf_obj); - if (leaf_obj > upper_bound_.load()) { break; } + if (leaf_obj > upper_bound_.load()) { + DEBUG_SUBMIP("{}Round {}: reached cutoff point. obj={:.4g}. upper_bound={:.4g}", + log_prefix, + round, + leaf_obj, + upper_bound_.load()) + break; + } if (num_frac == 0) { - // We found a feasible solution when fixing the variables in RINS. - add_feasible_solution(leaf_obj, current_sol, -1, search_strategy_t::SUBMIP); + // We found a feasible solution when fixing the variables in RINS/RENS. + add_feasible_solution(leaf_obj, current_sol, -1, worker->search_strategy); + DEBUG_SUBMIP("{}Round {}: found a solution with obj={:.4g}. upper_bound={:.4g}", + log_prefix, + round, + leaf_obj, + upper_bound_.load()) break; } - rins_worker->recompute_basis = false; + worker->recompute_basis = false; + ++round; } - f_t fixrate = (f_t)num_var_fixed / num_integers; + // Accumulate the iterations for sub-MIP so it stops when it reaches the allocated budget. + if (settings_.inside_submip) { + exploration_stats_.total_simplex_iters += stats.total_simplex_iters; + } if (has_submip) { // If not enough variables was fixed (the neighbourhood is too loose) or the sub-MIP already // found a solution that improved the incumbent, then do a DFS with a backtrack_limit of 5 // levels up to try to find a feasible solution quickly from the neighbourhood. if (fixrate < settings_.submip_settings.min_fixrate_cap || - (settings_.inside_submip && rins_stats_.total_success != 0)) { - // We need to re-populate the vstatus of the node since it was previously cleared. - rins_worker->start_node = std::move(node); - rins_worker->start_node.packed_vstatus = simplex::compress_vstatus(rins_worker->leaf_vstatus); + (settings_.inside_submip && submip_stats.total_success != 0)) { + worker->start_node.packed_vstatus = simplex::compress_vstatus(worker->leaf_vstatus); + worker->start_lower = lower; + worker->start_upper = upper; - rins_worker->start_lower = lower; - rins_worker->start_upper = upper; - - bool is_feasible = rins_worker->presolve_start_bounds(settings_); + bool is_feasible = worker->presolve_start_bounds(settings_); if (is_feasible) { fj_cpu_worker_t submip_fj_cpu_worker; @@ -2730,44 +2890,143 @@ void branch_and_bound_t::rins(diving_worker_t* rins_worker, f_t time_limit = std::max(settings_.time_limit - toc(exploration_stats_.start_time), 0); f_t work_limit = 1.0; - submip_fj_cpu_worker.create_worker(rins_worker->leaf_problem, - var_types_, - rins_worker->leaf_solution.x, + submip_fj_cpu_worker.create_worker(worker->leaf_problem, + var_types, + original_problem_.num_cols, + worker->leaf_solution.x, settings_, std::format("{} [CPU FJ]", log_prefix), - rins_worker->rng.next_i64()); + worker->rng.next_i64()); submip_fj_cpu_worker.run_sync(time_limit, work_limit); } - dive_with(rins_worker, 5); + // We need the pseudocost to do the DFS, which we do not have during the cut passes. + if (!is_root_heuristic) { + DEBUG_SUBMIP("{}Running a quick DFS. fixrate={:.4g} ({}/{})", + log_prefix, + fixrate, + fixrate * num_integers, + num_integers); + dive_with(worker, settings_.submip_settings.dfs_max_backtrack); + } } } else { - solve_submip( - rins_worker, current_incumbent, num_var_fixed, num_integers, submip_level, log_prefix); + solve_submip(worker, + current_incumbent, + var_types, + submip_stats, + fixrate, + stats.total_simplex_iters, + is_root_heuristic); } } - // Accumulate the iterations for sub-MIP so it stops when it reaches the allocated budget. - if (settings_.inside_submip) { - exploration_stats_.total_simplex_iters += rins_stats.total_simplex_iters; - } - - settings_.log.debug_format( - "{}success={}, infeasible={}, calls={}, fixrate={:.4g} ({}), max_fixrate={:.4g} ({}), " - "min_fixrate={:.4g} ({})\n", + DEBUG_SUBMIP( + "{}success={}, infeasible={}, calls={}, fixrate={:.4g} ({:.0f}/{}), max_fixrate={:.4g}, " + "min_fixrate={:.4g}\n", log_prefix, - rins_stats_.total_success.load(), - rins_stats_.total_infeasible.load(), - rins_stats_.total_calls.load(), + submip_stats.total_success.load(), + submip_stats.total_infeasible.load(), + submip_stats.total_calls.load(), fixrate, - num_var_fixed, + fixrate * num_integers, + num_integers, max_fixrate, - max_var_fixed, - min_fixrate, - min_var_fixed); + min_fixrate); + + // If the pool is uninitialized (i.e., in the root node), then this just inactivate the worker. + if (!is_root_heuristic) { + submip_worker_pool_.return_worker_to_pool(worker); + } else { + worker->set_inactive(); + } +} + +template +void branch_and_bound_t::launch_root_heuristics( + const lp_problem_t& lp, + const std::vector& sol, + i_t cut_pass, + root_heuristics_t& root_heuristics) +{ + if (settings_.deterministic) return; + if (settings_.num_threads < 2) return; + + // Using shared_ptr here, so the lifetime of the object is tied to the related task. This allows + // the solver to send the stop signal and immediately continue the execution. + auto current_heuristic = + root_heuristics.create_new_cut_pass_heuristic(cut_pass, Arow_, var_types_, sol, edge_norms_); + auto worker_count = root_heuristics.worker_count_; + constexpr bool is_root_heuristic = true; + constexpr bool is_cpufj_enabled = true; + + if (is_cpufj_enabled) { + f_t work_limit = std::numeric_limits::infinity(); + f_t time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + + // Odd passes start from the incumbent, even ones from the relaxation. The size guard covers a + // concurrent pass having grown the LP past the crush the incumbent was last taken through. + std::vector fj_seed; + if (cut_pass % 2 == 1) { + mutex_upper_.lock(); + if (incumbent_.has_incumbent && incumbent_.x.size() == (size_t)lp.num_cols) { + fj_seed = incumbent_.x; + } + mutex_upper_.unlock(); + } + if (fj_seed.empty()) { fj_seed = sol; } + + current_heuristic->fj_cpu_worker_.improvement_callback = + [this](f_t obj, const std::vector& assignment, double work_units) { + set_solution_from_cpu_fj(obj, assignment, work_units); + }; + current_heuristic->fj_cpu_worker_.create_worker(lp, + var_types_, + original_problem_.num_cols, + fj_seed, + settings_, + "[RootCut CPUFJ " + std::to_string(cut_pass) + + "] ", + /*seed=*/-1, + /*lane=*/cut_pass); + ++(*worker_count); + +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) \ + affinity(current_heuristic -> fj_cpu_worker_) firstprivate(current_heuristic, worker_count) \ + depend(out : current_heuristic->fj_cpu_worker_.fj_cpu) + { + current_heuristic->fj_cpu_worker_.run_sync(time_limit, work_limit); + --(*worker_count); + } + } + + bool use_rins = settings_.submip_settings.rins != 0 && incumbent_.has_incumbent; + if (use_rins || settings_.submip_settings.rens != 0) { + search_strategy_t strategy = use_rins ? search_strategy_t::RINS : search_strategy_t::RENS; + diving_worker_t* worker = current_heuristic->create_submip_worker( + cut_pass, lp, settings_, root_objective_, root_vstatus_, sol, strategy); - rins_worker_pool_.return_worker_to_pool(rins_worker); + std::vector current_incumbent; + mutex_upper_.lock(); + if (use_rins) current_incumbent = incumbent_.x; + mutex_upper_.unlock(); + + if (settings_.inside_submip) { + // LLVM libomp's GOMP compatibility path skips GCC's firstprivate copy + // function for included tasks. + recursive_submip(worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); + } else { + ++(*worker_count); +#pragma omp task priority(CUOPT_DEFAULT_TASK_PRIORITY) affinity(worker) \ + firstprivate(current_incumbent, current_heuristic, worker_count) depend(out : *worker) + { + recursive_submip( + worker, current_incumbent, current_heuristic->var_types_, is_root_heuristic); + --(*worker_count); + } + } + } } template @@ -3287,6 +3546,29 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut lp_status_t root_status = lp_status_t::UNSET; solving_root_relaxation_ = true; + // Started here so the lanes run through the root LP and every cut pass. No relaxation exists + // yet, so they seed from the anchor. + root_heuristics_t root_heuristics(settings_.num_threads - 1); + const i_t n_root_fj_lanes = + std::clamp(settings_.num_threads / 4, 0, CUOPT_MIP_ROOT_CPUFJ_MAX_LANES); + const f_t root_fj_time_limit = settings_.time_limit - toc(exploration_stats_.start_time); + if (!settings_.deterministic && n_root_fj_lanes > 0 && root_fj_time_limit > 0) { + root_heuristics.start_persistent_lanes( + original_lp_, + var_types_, + original_problem_.num_cols, + {}, + settings_, + n_root_fj_lanes, + root_fj_time_limit, + (int64_t)settings_.random_seed, + [this](f_t obj, const std::vector& assignment, double work_units) { + cuopt_assert(assignment.size() == (size_t)original_problem_.num_cols, + "root CPU FJ lanes must report a slack-free assignment"); + set_solution_from_cpu_fj(obj, assignment, work_units); + }); + } + f_t root_relax_start_time = tic(); if (!enable_concurrent_lp_root_solve()) { @@ -3316,13 +3598,14 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut nonbasic_list, edge_norms_); } + settings_.log.printf("\n"); solving_root_relaxation_ = false; f_t root_relax_elapsed_time = toc(root_relax_start_time); exploration_stats_.total_lp_solve_time = root_relax_elapsed_time; if (root_status == lp_status_t::INFEASIBLE) { - settings_.log.printf("\nThe root LP relaxation is infeasible\n", + settings_.log.printf("The root LP relaxation is infeasible\n", lp_status_to_string(root_status).c_str()); signal_extend_cliques_.store(true, std::memory_order_release); #pragma omp taskwait depend(in : *clique_signal) @@ -3330,7 +3613,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::UNBOUNDED) { - settings_.log.printf("\nThe root relaxation is unbounded\n", + settings_.log.printf("The root relaxation is unbounded\n", lp_status_to_string(root_status).c_str()); if (settings_.heuristic_preemption_callback != nullptr) { settings_.heuristic_preemption_callback(); @@ -3341,7 +3624,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::TIME_LIMIT) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::TIME_LIMIT; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3350,7 +3632,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::WORK_LIMIT) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::WORK_LIMIT; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3359,7 +3640,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } if (root_status == lp_status_t::NUMERICAL_ISSUES) { - settings_.log.printf("\n"); solver_status_ = mip_status_t::NUMERICAL; set_final_solution(solution, -inf); signal_extend_cliques_.store(true, std::memory_order_release); @@ -3368,7 +3648,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut } assert(root_status == lp_status_t::OPTIMAL); - settings_.log.printf("\n"); settings_.log.print_format("Root relaxation solution found in {} iterations and {:.2f}s by {}\n", root_relax_soln_.iterations, root_relax_elapsed_time, @@ -3447,13 +3726,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut compute_user_objective(original_lp_, root_relax_objective); } - constexpr bool enable_root_cut_cpufj = true; - fj_cpu_worker_t root_fj_cpu_worker; - root_fj_cpu_worker.improvement_callback = - [this](f_t obj, const std::vector& assignment, double work_units) { - set_solution_from_cpu_fj(obj, assignment, work_units); - }; - f_t cut_generation_start_time = tic(); i_t cut_pool_size = 0; for (i_t cut_pass = 0; cut_pass < settings_.max_cut_passes; cut_pass++) { @@ -3484,9 +3756,9 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return mip_status_t::OPTIMAL; } - cut_pass_result_t cut_pass_result; - root_fj_cpu_worker.run_async(settings_.time_limit - toc(exploration_stats_.start_time)); + launch_root_heuristics(original_lp_, root_relax_soln_.x, cut_pass, root_heuristics); + cut_pass_result_t cut_pass_result; cut_pass_result = do_cut_pass(cut_pass, solution, num_fractional, @@ -3505,7 +3777,15 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut root_relax_objective, cut_pool_size, saved_solution); - root_fj_cpu_worker.stop(); + + mutex_upper_.lock(); + if (incumbent_.has_incumbent && incumbent_.x.size() != original_lp_.num_cols) { + std::vector uncrushed_incumbent; + uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, uncrushed_incumbent); + crush_primal_solution( + original_problem_, original_lp_, uncrushed_incumbent, new_slacks_, incumbent_.x); + } + mutex_upper_.unlock(); if (cut_pass_result.action == cut_pass_action_t::RETURN) { if (settings_.benchmark_info_ptr != nullptr) { @@ -3516,16 +3796,6 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut return cut_pass_result.status; } if (cut_pass_result.action == cut_pass_action_t::BREAK) { break; } - - if (enable_root_cut_cpufj && !settings_.deterministic && settings_.num_threads >= 2 && - cut_pass + 1 < settings_.max_cut_passes) { - f_t root_cut_cpufj_build_start_time = tic(); - root_fj_cpu_worker.create_worker( - original_lp_, var_types_, root_relax_soln_.x, settings_, "[RootCut CPUFJ] "); - settings_.log.debug("Root cut CPUFJ problem build time after pass %d: %.6f seconds\n", - cut_pass, - toc(root_cut_cpufj_build_start_time)); - } } // Publish the post-cuts root LP value. @@ -3541,19 +3811,7 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut settings_.benchmark_info_ptr->cut_generation_time_sec = cut_generation_time; } if (cut_info.has_cuts()) { - // If the incumbent is set before or during the cut passes, it may not have the correct - // dimensions as cuts add additional constraints/variables to `original_lp_`. - mutex_upper_.lock(); - if (incumbent_.has_incumbent && incumbent_.x.size() != original_lp_.num_cols) { - std::vector uncrushed_incumbent; - uncrush_primal_solution(original_problem_, original_lp_, incumbent_.x, uncrushed_incumbent); - crush_primal_solution( - original_problem_, original_lp_, uncrushed_incumbent, new_slacks_, incumbent_.x); - } - - mutex_upper_.unlock(); - - settings_.log.printf("Cut generation time: %.2f seconds\n", cut_generation_time); + settings_.log.printf("Root cut passes time: %.2f seconds\n", cut_generation_time); settings_.log.printf("Cut pool size : %d\n", cut_pool_size); settings_.log.printf("Size with cuts : %d constraints, %d variables, %d nonzeros\n", original_lp_.num_rows, @@ -3563,30 +3821,8 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut settings_.log.printf("\n"); } - if (enable_root_cut_cpufj && cut_info.has_cuts()) { - f_t root_cut_cpufj_build_start_time = tic(); - // In deterministic mode this CPUFJ is built on the B&B task while the LS deterministic - // CPUFJ is being built on the main thread; both would otherwise race on the global - // seed_generator and pick non-reproducible seeds. Pin a stable seed here so this - // climber's behavior depends only on settings_.random_seed. - int64_t root_cut_cpufj_seed = - settings_.deterministic ? static_cast(settings_.random_seed) : -1; - root_fj_cpu_worker.create_worker(original_lp_, - var_types_, - root_relax_soln_.x, - settings_, - "[RootCut CPUFJ] ", - root_cut_cpufj_seed); - settings_.log.debug("Root cut CPUFJ final problem build time: %.6f seconds\n", - toc(root_cut_cpufj_build_start_time)); - f_t remaining_time = f_t(settings_.time_limit - toc(exploration_stats_.start_time)); - // Reserve at least half of the remaining time for B&B exploration; cap absolute spend - // at 1s so generous budgets don't grant CPUFJ more than the historical ceiling. - f_t fj_time_limit = - settings_.deterministic ? remaining_time : std::min(remaining_time * 0.5, 1.0); - root_fj_cpu_worker.run_sync(fj_time_limit, 0.5); - } - + // Stops the root heuristics and clear the associated data + root_heuristics.stop_and_sync(); set_uninitialized_steepest_edge_norms(original_lp_, basic_list, edge_norms_); pc_.resize(original_lp_.num_cols); @@ -3711,9 +3947,23 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut const i_t num_bfs_workers = std::max(num_workers / 2, 1); const i_t num_submip_workers = std::max(num_workers / 8, 1); const i_t num_diving_workers = std::max(num_workers - num_bfs_workers, 1); - bfs_worker_pool_.init(num_bfs_workers, original_lp_, Arow_, var_types_, symmetry_, settings_); - rins_worker_pool_.init( - num_submip_workers, original_lp_, Arow_, var_types_, symmetry_, settings_, num_bfs_workers); + bfs_worker_pool_.init(num_bfs_workers, + original_lp_, + Arow_, + var_types_, + symmetry_, + settings_, + root_relax_soln_.x, + edge_norms_); + submip_worker_pool_.init(num_submip_workers, + original_lp_, + Arow_, + var_types_, + symmetry_, + settings_, + root_relax_soln_.x, + edge_norms_, + num_bfs_workers); if (num_diving_workers > 0) { diving_worker_pool_.init(num_diving_workers, @@ -3722,6 +3972,8 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut var_types_, symmetry_, settings_, + root_relax_soln_.x, + edge_norms_, num_bfs_workers + num_submip_workers); } @@ -3766,6 +4018,18 @@ mip_status_t branch_and_bound_t::solve(mip_solution_t& solut if (!std::isfinite(lower_bound)) { lower_bound = search_tree_.root.lower_bound; } } + DEBUG_SUBMIP("RINS: success={}, infeasible={}, empty={}, calls={}", + rins_stats_.total_success.load(), + rins_stats_.total_infeasible.load(), + rins_stats_.total_empty.load(), + rins_stats_.total_calls.load()); + + DEBUG_SUBMIP("RENS: success={}, infeasible={}, empty={}, calls={}", + rens_stats_.total_success.load(), + rens_stats_.total_infeasible.load(), + rens_stats_.total_empty.load(), + rens_stats_.total_calls.load()); + set_final_solution(solution, lower_bound); return solver_status_; } @@ -3904,7 +4168,7 @@ void branch_and_bound_t::run_deterministic_coordinator(const csr_matri deterministic_global_termination_status_ = mip_status_t::UNSET; deterministic_workers_ = std::make_unique>( - num_bfs_workers, original_lp_, Arow, var_types_, settings_); + num_bfs_workers, original_lp_, Arow, var_types_, settings_, root_relax_soln_.x, edge_norms_); if (num_diving_workers > 0) { // Extract diving types from search_strategies (skip BEST_FIRST at index 0) @@ -3923,7 +4187,8 @@ void branch_and_bound_t::run_deterministic_coordinator(const csr_matri Arow, var_types_, settings_, - &root_relax_soln_.x); + root_relax_soln_.x, + edge_norms_); } } diff --git a/cpp/src/branch_and_bound/branch_and_bound.hpp b/cpp/src/branch_and_bound/branch_and_bound.hpp index 96b8a6d8fe..7cf5ed3680 100644 --- a/cpp/src/branch_and_bound/branch_and_bound.hpp +++ b/cpp/src/branch_and_bound/branch_and_bound.hpp @@ -34,12 +34,14 @@ #include #include +#include #include #include #include #include +#include #include #include @@ -273,8 +275,9 @@ class branch_and_bound_t { diving_worker_pool_t diving_worker_pool_; // Worker pool dedicated to recursive RINS - diving_worker_pool_t rins_worker_pool_; + diving_worker_pool_t submip_worker_pool_; submip_stats_t rins_stats_; + submip_stats_t rens_stats_; // Global status of the solver. omp_atomic_t solver_status_; @@ -367,25 +370,33 @@ class branch_and_bound_t { void dive_with(diving_worker_t* worker, i_t backtrack_limit); // Launch a new RINS worker - bool launch_rins_worker(const std::vector& sol); - void set_solution_from_submip(const std::vector& solution, + bool launch_submip_worker(const std::vector& sol); + void set_solution_from_submip(const simplex::lp_problem_t& lp, + const std::vector& solution, const third_party_presolve_t& presolver, + submip_stats_t& submip_stats, f_t fixrate, - f_t obj); + std::string_view log_prefix); // Solve the RINS sub-MIP void solve_submip(diving_worker_t* worker, const std::vector& current_incumbent, - i_t num_var_fixed, - i_t num_integers, - i_t submip_level, - std::string_view log_prefix); + const std::vector& var_types, + submip_stats_t& submip_stats, + f_t fixrate, + i_t simplex_iter_used, + bool is_root_heuristic = false); // Creates and solves the RINS sub-MIP - void rins(diving_worker_t* rins_worker, const std::vector& node_solution); - - // Get the simplex settings for solving the LP of a single node - simplex::simplex_solver_settings_t get_node_lp_settings(); + void recursive_submip(diving_worker_t* worker, + const std::vector& current_incumbent, + const std::vector& var_types, + bool is_root_heuristic = false); + + void launch_root_heuristics(const simplex::lp_problem_t& lp, + const std::vector& sol, + i_t cut_pass, + root_heuristics_t& root_heuristics); // Solve the LP relaxation of a leaf node simplex::dual_status_t solve_node_lp(mip_node_t* node_ptr, diff --git a/cpp/src/branch_and_bound/constants.hpp b/cpp/src/branch_and_bound/constants.hpp index 5629360a86..59f43549d8 100644 --- a/cpp/src/branch_and_bound/constants.hpp +++ b/cpp/src/branch_and_bound/constants.hpp @@ -25,17 +25,37 @@ enum class heuristics_origin_t { // [3] E. Danna, E. Rothberg, and C. L. Pape, “Exploring relaxation induced neighborhoods to // improve MIP solutions,” Math. Program., vol. 102, no. 1, pp. 71–90, Jan. 2005, // doi: 10.1007/s10107-004-0518-7. +// [4] T. Berthold, “RENS: The optimal rounding,” Math. Prog. Comp., vol. 6, no. 1, +// pp. 33–54, Mar. 2014, doi: 10.1007/s12532-013-0060-9. enum class search_strategy_t : int { BEST_FIRST = 0, // Best-First + Plunging. - PSEUDOCOST_DIVING = 1, // Pseudocost diving (9.2.5) - LINE_SEARCH_DIVING = 2, // Line search diving (9.2.4) - GUIDED_DIVING = 3, // Guided diving (9.2.3). - COEFFICIENT_DIVING = 4, // Coefficient diving (9.2.1) + PSEUDOCOST_DIVING = 1, // Pseudocost diving [1, Section 9.2.5] + LINE_SEARCH_DIVING = 2, // Line search diving [1, Section 9.2.4] + GUIDED_DIVING = 3, // Guided diving. [1, Section 9.2.3] + COEFFICIENT_DIVING = 4, // Coefficient diving [1, Section 9.2.1] FARKAS_DIVING = 5, // Farkas Diving (see [2]) - VECTOR_LENGTH_DIVING = 6, // Vector Length Diving (9.2.6) - SUBMIP = 7 // RINS (see [3]) + VECTOR_LENGTH_DIVING = 6, // Vector Length Diving [1, Section 9.2.6] + RINS = 7, // RINS (see [3]) + RENS = 8 // RENS (see [1, Section 9.1.1], [4]) }; enum class branch_direction_t { NONE = -1, DOWN = 0, UP = 1 }; +inline const char* search_strategy_to_string(search_strategy_t search_strategy) +{ + switch (search_strategy) { + case search_strategy_t::BEST_FIRST: return "BEST_FIRST"; + case search_strategy_t::PSEUDOCOST_DIVING: return "PSEUDOCOST_DIVING"; + case search_strategy_t::LINE_SEARCH_DIVING: return "LINE_SEARCH_DIVING"; + case search_strategy_t::GUIDED_DIVING: return "GUIDED_DIVING"; + case search_strategy_t::COEFFICIENT_DIVING: return "COEFFICIENT_DIVING"; + case search_strategy_t::FARKAS_DIVING: return "FARKAS_DIVING"; + case search_strategy_t::VECTOR_LENGTH_DIVING: return "VECTOR_LENGTH_DIVING"; + case search_strategy_t::RINS: return "RINS"; + case search_strategy_t::RENS: return "RENS"; + } + + return "UNKNOWN"; +} + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/branch_and_bound/deterministic_workers.hpp b/cpp/src/branch_and_bound/deterministic_workers.hpp index 4de3086e61..7c426452a8 100644 --- a/cpp/src/branch_and_bound/deterministic_workers.hpp +++ b/cpp/src/branch_and_bound/deterministic_workers.hpp @@ -89,8 +89,10 @@ class deterministic_worker_base_t : public branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, const std::string& context_name) - : base_t(id, original_lp, Arow, var_types, settings), + : base_t(id, original_lp, Arow, var_types, settings, root_solution, root_edge_norm), work_context(context_name), pc_snapshot(1, settings) { @@ -140,8 +142,17 @@ class deterministic_bfs_worker_t const simplex::lp_problem_t& original_lp, const csr_matrix_t& Arow, const std::vector& var_types, - const simplex::simplex_solver_settings_t& settings) - : base_t(id, original_lp, Arow, var_types, settings, "BB_Worker_" + std::to_string(id)) + const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm) + : base_t(id, + original_lp, + Arow, + var_types, + settings, + root_solution, + root_edge_norm, + "BB_Worker_" + std::to_string(id)) { } @@ -282,9 +293,6 @@ class deterministic_diving_worker_t std::vector dive_lower; std::vector dive_upper; - // Root LP relaxation solution (constant, set once at construction) - const std::vector* root_solution{nullptr}; - // Diving state bool recompute_bounds_and_basis{true}; @@ -300,10 +308,17 @@ class deterministic_diving_worker_t const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, - const std::vector* root_sol) - : base_t(id, original_lp, Arow, var_types, settings, "Diving_Worker_" + std::to_string(id)), - diving_type(type), - root_solution(root_sol) + const std::vector& root_solution, + const std::vector& root_edge_norm) + : base_t(id, + original_lp, + Arow, + var_types, + settings, + root_solution, + root_edge_norm, + "Diving_Worker_" + std::to_string(id)), + diving_type(type) { dive_lower = original_lp.lower; dive_upper = original_lp.upper; @@ -407,11 +422,14 @@ class deterministic_bfs_worker_pool_t const simplex::lp_problem_t& original_lp, const csr_matrix_t& Arow, const std::vector& var_types, - const simplex::simplex_solver_settings_t& settings) + const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm) { this->workers_.reserve(num_workers); for (int i = 0; i < num_workers; ++i) { - this->workers_.emplace_back(i, original_lp, Arow, var_types, settings); + this->workers_.emplace_back( + i, original_lp, Arow, var_types, settings, root_solution, root_edge_norm); } } @@ -443,12 +461,14 @@ class deterministic_diving_worker_pool_t const csr_matrix_t& Arow, const std::vector& var_types, const simplex::simplex_solver_settings_t& settings, - const std::vector* root_solution) + const std::vector& root_solution, + const std::vector& root_edge_norm) { this->workers_.reserve(num_workers); for (int i = 0; i < num_workers; ++i) { search_strategy_t type = diving_types[i % diving_types.size()]; - this->workers_.emplace_back(i, type, original_lp, Arow, var_types, settings, root_solution); + this->workers_.emplace_back( + i, type, original_lp, Arow, var_types, settings, root_solution, root_edge_norm); } } diff --git a/cpp/src/branch_and_bound/worker.hpp b/cpp/src/branch_and_bound/worker.hpp index fd5255f86f..bbc2836d43 100644 --- a/cpp/src/branch_and_bound/worker.hpp +++ b/cpp/src/branch_and_bound/worker.hpp @@ -78,6 +78,9 @@ class branch_and_bound_worker_t { bool recompute_basis = true; bool recompute_bounds = true; + const std::vector& root_solution; + const std::vector& root_edge_norm; + void ensure_orbital_fixing() { if (orbital_fixing == nullptr && symmetry_ptr != nullptr) { @@ -94,6 +97,8 @@ class branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_type, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, uint64_t rng_offset = 0) : worker_id(worker_id), search_strategy(search_strategy_t::BEST_FIRST), @@ -108,7 +113,9 @@ class branch_and_bound_worker_t { node_presolver(leaf_problem, Arow, {}, var_type), bounds_changed(original_lp.num_cols, false), rng(settings.random_seed + pcgenerator_t::default_seed + rng_offset + worker_id, - pcgenerator_t::default_stream ^ (worker_id + rng_offset)) + pcgenerator_t::default_stream ^ (worker_id + rng_offset)), + root_solution(root_solution), + root_edge_norm(root_edge_norm) { } @@ -146,8 +153,11 @@ class bfs_worker_t : public branch_and_bound_worker_t { const csr_matrix_t& Arow, const std::vector& var_type, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, uint64_t rng_offset = 0) - : Base(worker_id, original_lp, Arow, var_type, settings, rng_offset) + : Base( + worker_id, original_lp, Arow, var_type, settings, root_solution, root_edge_norm, rng_offset) { this->start_lower = original_lp.lower; this->start_upper = original_lp.upper; @@ -243,6 +253,8 @@ class diving_worker_t : public branch_and_bound_worker_t { // The best-first worker that is associated with this diving worker. Used for controlling the // number of active diving workers. bfs_worker_t* bfs_worker{nullptr}; + + std::atomic halt = false; }; struct submip_stats_t { @@ -251,6 +263,7 @@ struct submip_stats_t { omp_atomic_t total_infeasible = 0; omp_atomic_t infeasible_fixrate_sum = 0; omp_atomic_t total_calls = 0; + omp_atomic_t total_empty = 0; void save_success(double fixrate) { @@ -264,6 +277,7 @@ struct submip_stats_t { infeasible_fixrate_sum += fixrate; } + void save_empty() { ++total_empty; } double average_infeasible_fixrate() const { return infeasible_fixrate_sum / total_infeasible; } double average_success_fixrate() const { return success_fixrate_sum / total_success; } }; diff --git a/cpp/src/branch_and_bound/worker_pool.hpp b/cpp/src/branch_and_bound/worker_pool.hpp index e9d55bafe3..6977c7882b 100644 --- a/cpp/src/branch_and_bound/worker_pool.hpp +++ b/cpp/src/branch_and_bound/worker_pool.hpp @@ -24,6 +24,8 @@ class worker_pool_t { const std::vector& var_type, mip_symmetry_t* symmetry, const simplex::simplex_solver_settings_t& settings, + const std::vector& root_solution, + const std::vector& root_edge_norm, const uint64_t rng_offset = 0) { assert(!is_initialized_); @@ -33,8 +35,8 @@ class worker_pool_t { num_idle_workers_ = num_workers; idle_workers_.clear_resize(num_workers); for (i_t i = 0; i < num_workers; ++i) { - workers_[i] = - std::make_unique(i, original_lp, Arow, var_type, settings, rng_offset); + workers_[i] = std::make_unique( + i, original_lp, Arow, var_type, settings, root_solution, root_edge_norm, rng_offset); idle_workers_.push_back(i); // Propagate the (possibly null) symmetry pointer; workers lazily build // their orbital_fixing/lexical_reduction state via ensure_orbital_fixing(). @@ -61,14 +63,17 @@ class worker_pool_t { void return_worker_to_pool(WorkerType* worker) { - std::lock_guard lock(mutex_); assert(worker != nullptr); + worker->set_inactive(); + assert(!worker->is_active.load()); + + if (!is_initialized_) return; + + std::lock_guard lock(mutex_); assert(workers_[worker->worker_id].get() == worker); assert(static_cast(num_idle_workers_.load()) == idle_workers_.size()); assert(idle_workers_.size() <= workers_.size()); - worker->set_inactive(); - assert(!worker->is_active.load()); idle_workers_.push_back(worker->worker_id); num_idle_workers_++; } diff --git a/cpp/src/cuts/CMakeLists.txt b/cpp/src/cuts/CMakeLists.txt index 813ac88a59..2d4412d00a 100644 --- a/cpp/src/cuts/CMakeLists.txt +++ b/cpp/src/cuts/CMakeLists.txt @@ -6,6 +6,7 @@ set(CUTS_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/cuts.cpp ${CMAKE_CURRENT_SOURCE_DIR}/objective_step.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/zero_half_mod2.cpp ) set(CUOPT_SRC_FILES ${CUOPT_SRC_FILES} diff --git a/cpp/src/cuts/cuts.cpp b/cpp/src/cuts/cuts.cpp index be45ffeecd..7acd7dee0a 100644 --- a/cpp/src/cuts/cuts.cpp +++ b/cpp/src/cuts/cuts.cpp @@ -2324,7 +2324,8 @@ i_t knapsack_generation_t::generate_knapsack_cut( const std::vector& var_types, const std::vector& xstar, i_t knapsack_row, - inequality_t& cut) + inequality_t& cut, + f_t start_time) { const bool verbose = false; // Get the row associated with the knapsack constraint @@ -2499,7 +2500,8 @@ i_t knapsack_generation_t::generate_knapsack_cut( // Lift the cut inequality_t lifted_cut(lp.num_cols); - lift_knapsack_cut(knapsack_inequality, minimal_cover_cut, c1_partition, c2_partition, lifted_cut); + lift_knapsack_cut( + knapsack_inequality, minimal_cover_cut, c1_partition, c2_partition, lifted_cut, start_time); lifted_cut.negate(); // The cut is now in the form: @@ -2679,7 +2681,8 @@ void knapsack_generation_t::lift_knapsack_cut( const inequality_t& base_cut, const std::vector& c1_partition, const std::vector& c2_partition, - inequality_t& lifted_cut) + inequality_t& lifted_cut, + f_t start_time) { // The base cut is in the form: sum_{j in cover} x_j <= |cover| - 1 @@ -2795,14 +2798,15 @@ void knapsack_generation_t::lift_knapsack_cut( best_score_last_permutation(remaining_coefficients, permutation); while (permutation.size() > 0) { + if (toc(start_time) >= settings_.time_limit) { break; } const i_t h = permutation.back(); const i_t k = remaining_variables[h]; const f_t a_k = remaining_coefficients[h]; f_t capacity = knapsack_inequality.rhs - a_k; - f_t objective = - exact_knapsack_problem_integer_values_fraction_values(values, weights, capacity, solution); + f_t objective = exact_knapsack_problem_integer_values_fraction_values( + values, weights, capacity, solution, start_time); if (std::isnan(objective)) { settings_.log.debug("lifting knapsack problem failed\n"); break; @@ -3021,8 +3025,10 @@ f_t knapsack_generation_t::exact_knapsack_problem_integer_values_fract const std::vector& values, const std::vector& weights, f_t rhs, - std::vector& solution) + std::vector& solution, + f_t start_time) { + if (toc(start_time) >= settings_.time_limit) { return std::numeric_limits::quiet_NaN(); } // Solve the knapsack problem // maximize sum_{j=0}^n values[j] * solution[j] // subject to sum_{j=0}^n weights[j] * solution[j] <= rhs @@ -3050,6 +3056,7 @@ f_t knapsack_generation_t::exact_knapsack_problem_integer_values_fract // 4. Dynamic programming for (i_t j = 1; j <= n; ++j) { + if (toc(start_time) >= settings_.time_limit) { return std::numeric_limits::quiet_NaN(); } for (i_t v = 0; v <= sum_value; ++v) { // Do not take item i-1 dp(j, v) = dp(j - 1, v); @@ -3095,19 +3102,27 @@ void cut_generation_t::generate_implied_bound_cuts( { if (probing_implied_bound_.zero_offsets.empty()) { return; } - const f_t tol = 1e-4; - i_t num_cuts = 0; - const i_t pib_cols = static_cast(probing_implied_bound_.zero_offsets.size()) - 1; - const i_t n_cols = std::min(lp.num_cols, pib_cols); + const f_t tol = 1e-4; + i_t num_cuts = 0; + const i_t pib_cols = probing_implied_bound_.zero_offsets.size() - 1; + const i_t n_cols = std::min(lp.num_cols, pib_cols); + f_t work_estimate = 0.0; + const f_t max_work_estimate = 1e8; + constexpr f_t implication_work = 16.0; + constexpr f_t generated_cut_work = 16.0; for (i_t j = 0; j < n_cols; j++) { + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } if (var_types[j] == variable_type_t::CONTINUOUS) { continue; } const f_t xstar_j = xstar[j]; // x_j = 0 implications const i_t zero_begin = probing_implied_bound_.zero_offsets[j]; const i_t zero_end = probing_implied_bound_.zero_offsets[j + 1]; + const i_t one_begin = probing_implied_bound_.one_offsets[j]; + const i_t one_end = probing_implied_bound_.one_offsets[j + 1]; for (i_t p = zero_begin; p < zero_end; p++) { + work_estimate += implication_work; const i_t i = probing_implied_bound_.zero_variables[p]; if (i == j) { continue; } const f_t l_i = lp.lower[i]; @@ -3127,6 +3142,7 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = -b_ub; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } @@ -3145,15 +3161,16 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = b_lb; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } } + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } // x_j = 1 implications - const i_t one_begin = probing_implied_bound_.one_offsets[j]; - const i_t one_end = probing_implied_bound_.one_offsets[j + 1]; for (i_t p = one_begin; p < one_end; p++) { + work_estimate += implication_work; const i_t i = probing_implied_bound_.one_variables[p]; if (i == j) { continue; } const f_t l_i = lp.lower[i]; @@ -3173,6 +3190,7 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = -u_i; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } @@ -3190,10 +3208,12 @@ void cut_generation_t::generate_implied_bound_cuts( cut.push_back(j, coeff_j); cut.rhs = rhs_val; cut_pool_.add_cut(cut_type_t::IMPLIED_BOUND, cut); + work_estimate += generated_cut_work; num_cuts++; } } } + if (work_estimate > max_work_estimate || toc(start_time) >= settings.time_limit) { return; } } if (num_cuts > 0) { @@ -3604,7 +3624,8 @@ bool cut_generation_t::generate_cuts(const lp_problem_t& lp, if (toc(start_time) >= settings.time_limit) { return true; } ZERO_HALF_DEBUG("generate_cuts: about to call generate_zero_half_cuts"); f_t cut_start_time = tic(); - bool feasible = generate_zero_half_cuts(lp, settings, var_types, xstar, zstar, start_time); + bool feasible = generate_zero_half_cuts( + lp, settings, Arow, new_slacks, var_types, xstar, zstar, variable_bounds, start_time); ZERO_HALF_DEBUG("generate_cuts: returned from generate_zero_half_cuts feasible=%d", static_cast(feasible)); if (!feasible) { @@ -3637,7 +3658,7 @@ void cut_generation_t::generate_knapsack_cuts( if (toc(start_time) >= settings.time_limit) { return; } inequality_t cut(lp.num_cols); i_t knapsack_status = knapsack_generation_.generate_knapsack_cut( - lp, settings, Arow, new_slacks, var_types, xstar, knapsack_row, cut); + lp, settings, Arow, new_slacks, var_types, xstar, knapsack_row, cut, start_time); if (knapsack_status == 0) { cut_pool_.add_cut(cut_type_t::KNAPSACK, cut); } } } @@ -3855,9 +3876,12 @@ template bool cut_generation_t::generate_zero_half_cuts( const lp_problem_t& lp, const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, const std::vector& var_types, const std::vector& xstar, const std::vector& reduced_costs, + variable_bounds_t& variable_bounds, f_t start_time) { if (settings.zero_half_cuts == 0) { return true; } @@ -3882,12 +3906,25 @@ bool cut_generation_t::generate_zero_half_cuts( static_cast(sub_cg_.ready), sub_cg_.vertices.size()); + f_t mod2_work_estimate = 0.0; + const bool mod2_completed = generate_mod2_zero_half_cuts(cut_pool_, + lp, + settings, + Arow, + new_slacks, + var_types, + xstar, + variable_bounds, + start_time, + mod2_work_estimate); + if (!mod2_completed) { return true; } + // The fractional conflict-graph subgraph is built once per cut pass in - // prepare_fractional_sub_conflict_graph() (called from generate_cuts) and shared with - // the clique-cut separator. Skip if the build was unable to produce a - // useable sub-CG (clique table missing/empty, work/time budget hit, etc.). + // prepare_fractional_sub_conflict_graph() and remains a complementary + // odd-cycle / odd-wheel separator. If no conflict graph is available, the + // general row-parity cuts above are still retained. if (!sub_cg_.ready) { - ZERO_HALF_DEBUG("sub_cg_ not ready, skipping"); + ZERO_HALF_DEBUG("sub_cg_ not ready, skipping odd-cycle path"); return true; } if (sub_cg_.empty_subgraph()) { @@ -3905,8 +3942,8 @@ bool cut_generation_t::generate_zero_half_cuts( cuopt_assert(user_problem_.var_types.size() == static_cast(num_vars), "Zero-half user problem var_types size mismatch"); - const f_t min_violation = std::max(settings.primal_tol, static_cast(1e-6)); - const f_t bound_tol = settings.primal_tol; + constexpr f_t min_violation = (f_t)1e-6; + const f_t bound_tol = settings.primal_tol; // shortest path of length >= 0.5 - min_violation cannot yield a violated cut const f_t cutoff = static_cast(0.5) - min_violation; f_t work_estimate = 0.0; @@ -4070,21 +4107,22 @@ void cut_generation_t::generate_mir_cuts( // at the beginning of each iteration of the for loop below std::vector aggregated_rows; std::vector aggregated_mark(lp.num_rows, 0); + const i_t max_cuts = std::min(lp.num_rows, 100000); // Transform the relaxation solution std::vector transformed_xstar; complemented_mir.bound_substitution(lp, variable_bounds, var_types, xstar, transformed_xstar); - const i_t max_cuts = std::min(lp.num_rows, 100000); f_t work_estimate = 0.0; - i_t num_cuts = 0; - while (num_cuts < max_cuts && !score_queue.empty()) { + i_t cuts_processed = 0; + while (cuts_processed < max_cuts && !score_queue.empty()) { if (toc(start_time) >= settings.time_limit) { break; } // Get the row with the highest score from the queue auto [max_score, i] = score_queue.top(); score_queue.pop(); // skip stale score entries if (max_score != scores[i]) { continue; } + ++cuts_processed; // Add the current row to the aggregated set aggregated_mark[i] = 1; @@ -5304,7 +5342,8 @@ void complemented_mixed_integer_rounding_cut_t::bound_substitution( const variable_bounds_t& variable_bounds, const std::vector& var_types, const std::vector& xstar, - std::vector& transformed_xstar) + std::vector& transformed_xstar, + bool prefer_variable_bound_on_tie) { transformed_xstar.resize(lp.num_cols); // Perform bound substitution for continuous variables @@ -5368,8 +5407,12 @@ void complemented_mixed_integer_rounding_cut_t::bound_substitution( bound_changed_[j] = 0; continue; } - if (has_finite_lower_bound && - (!has_finite_upper_bound || (xstar_j - lb_star_[j] <= ub_star_[j] - xstar_j))) { + const f_t lower_distance = xstar_j - lb_star_[j]; + const f_t upper_distance = ub_star_[j] - xstar_j; + const bool prefer_upper_variable_bound = + prefer_variable_bound_on_tie && ub_variable_[j] >= 0 && lower_distance == upper_distance; + if (has_finite_lower_bound && (!has_finite_upper_bound || (lower_distance <= upper_distance && + !prefer_upper_variable_bound))) { // Use the lower bound // lb_star_j <= x_j <= ub_star_j // v_j = x_j - lb_star_j, @@ -5693,7 +5736,10 @@ f_t complemented_mixed_integer_rounding_cut_t::compute_violation( template void complemented_mixed_integer_rounding_cut_t::substitute_slacks( - const lp_problem_t& lp, csr_matrix_t& Arow, inequality_t& cut) + const lp_problem_t& lp, + csr_matrix_t& Arow, + inequality_t& cut, + f_t* work_estimate) { // Remove slacks from the cut // So that the cut is only over the original variables @@ -5701,6 +5747,7 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( i_t cut_nz = 0; std::vector cut_indices; cut_indices.reserve(cut.size()); + if (work_estimate != nullptr) { *work_estimate += cut.size(); } for (i_t k = 0; k < cut.size(); k++) { const i_t j = cut.index(k); @@ -5743,6 +5790,7 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( cut.rhs -= cj * lp.rhs[i] / alpha; const i_t row_start = Arow.row_start[i]; const i_t row_end = Arow.row_start[i + 1]; + if (work_estimate != nullptr) { *work_estimate += row_end - row_start; } for (i_t q = row_start; q < row_end; q++) { const i_t h = Arow.j[q]; if (h != j) { @@ -5764,6 +5812,9 @@ void complemented_mixed_integer_rounding_cut_t::substitute_slacks( if (found_slack) { scratch_pad_.get_pad(cut.vector.i, cut.vector.x); + if (work_estimate != nullptr) { + *work_estimate += 2 * cut.size() + cut.size() * std::log2((f_t)cut.size() + (f_t)1.0); + } // Sort the cut cut.sort(); } diff --git a/cpp/src/cuts/cuts.hpp b/cpp/src/cuts/cuts.hpp index 78091c85f6..fcb6080178 100644 --- a/cpp/src/cuts/cuts.hpp +++ b/cpp/src/cuts/cuts.hpp @@ -286,6 +286,23 @@ std::vector> find_violated_odd_cycles_for_test( double min_violation, double time_limit); +// Test-only helper to run the production sparse GF(2) row-dependency finder used +// by general zero-half cuts. Each parity row contains the integer-variable +// indices with an odd coefficient. A returned combination has even aggregate +// parity and an odd aggregate rhs. +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations); +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations, + double max_work_estimate, + double* work_estimate); + template class cut_pool_t { public: @@ -346,6 +363,18 @@ class cut_pool_t { template class variable_bounds_t; +template +bool generate_mod2_zero_half_cuts(cut_pool_t& cut_pool, + const simplex::lp_problem_t& lp, + const simplex::simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + f_t start_time, + f_t& work_estimate); + template struct flow_cover_row_t { i_t row; @@ -543,7 +572,8 @@ class knapsack_generation_t { const std::vector& var_types, const std::vector& xstar, i_t knapsack_row, - inequality_t& cut); + inequality_t& cut, + f_t start_time); i_t num_knapsack_constraints() const { return knapsack_constraints_.size(); } const std::vector& get_knapsack_constraints() const { return knapsack_constraints_; } @@ -568,7 +598,8 @@ class knapsack_generation_t { const inequality_t& base_cut, const std::vector& c1_partition, const std::vector& c2_partition, - inequality_t& lifted_cut); + inequality_t& lifted_cut, + f_t start_time); // Solve a 0-1 knapsack problem using dynamic programming f_t solve_knapsack_problem(const std::vector& values, @@ -579,7 +610,8 @@ class knapsack_generation_t { f_t exact_knapsack_problem_integer_values_fraction_values(const std::vector& values, const std::vector& weights, f_t rhs, - std::vector& solution); + std::vector& solution, + f_t start_time); std::vector is_slack_; std::vector knapsack_constraints_; @@ -709,12 +741,16 @@ class cut_generation_t { const std::vector& reduced_costs, f_t start_time); - // Generate zero-half (odd-cycle / odd-wheel) cuts from the conflict graph + // Generate general row-parity zero-half cuts and conflict-graph + // odd-cycle / odd-wheel cuts. bool generate_zero_half_cuts(const simplex::lp_problem_t& lp, const simplex::simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, const std::vector& var_types, const std::vector& xstar, const std::vector& reduced_costs, + variable_bounds_t& variable_bounds, f_t start_time); // Generate implied bounds cuts from probing implications @@ -955,7 +991,8 @@ class complemented_mixed_integer_rounding_cut_t { const variable_bounds_t& variable_bounds, const std::vector& var_types, const std::vector& xstar, - std::vector& transformed_xstar); + std::vector& transformed_xstar, + bool prefer_variable_bound_on_tie = false); // Converts an inequality of the form: sum_j a_j x_j >= beta // with l_j <= x_j <= u_j into the form: @@ -998,6 +1035,15 @@ class complemented_mixed_integer_rounding_cut_t { const std::vector& var_types, inequality_t& cut); + // Generate a lifted mixed-binary cover inequality from a transformed + // nonnegative >= row. Returns the cut in the same >= convention. + bool generate_lifted_mixed_binary_cover(const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + f_t& work_estimate, + f_t max_work_estimate); + f_t compute_violation(const inequality_t& cut, const std::vector& xstar); f_t new_upper(i_t j) const { return transformed_upper_[j]; } @@ -1011,7 +1057,8 @@ class complemented_mixed_integer_rounding_cut_t { void substitute_slacks(const simplex::lp_problem_t& lp, csr_matrix_t& Arow, - inequality_t& cut); + inequality_t& cut, + f_t* work_estimate = nullptr); // Combine the pivot row with the inequality to eliminate the variable j // The new inequality is returned in inequality and inequality_rhs diff --git a/cpp/src/cuts/zero_half_mod2.cpp b/cpp/src/cuts/zero_half_mod2.cpp new file mode 100644 index 0000000000..c34dd162f5 --- /dev/null +++ b/cpp/src/cuts/zero_half_mod2.cpp @@ -0,0 +1,770 @@ +/* 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::mip { + +using simplex::lp_problem_t; +using simplex::simplex_solver_settings_t; +using simplex::variable_type_t; + +namespace { + +template +void symmetric_difference_sorted(const std::vector& a, + const std::vector& b, + std::vector& result) +{ + result.clear(); + result.reserve(a.size() + b.size()); + std::set_symmetric_difference(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(result)); +} + +template +struct mod2_parity_row_t { + std::vector parity; + bool rhs_parity{false}; +}; + +template +struct mod2_candidate_t : mod2_parity_row_t { + inequality_t transformed_inequality; + bool reversible{false}; +}; + +template +struct mod2_basis_row_t { + std::vector parity; + std::vector combination; + bool rhs{false}; +}; + +template +struct mod2_row_order_t { + const std::vector& rows; + + bool operator()(i_t a, i_t b) const + { + if (rows[a].parity.size() != rows[b].parity.size()) { + return rows[a].parity.size() < rows[b].parity.size(); + } + return rows[a].rhs_parity < rows[b].rhs_parity; + } +}; + +template +std::vector> find_mod2_row_combinations(const std::vector& rows, + i_t max_combination_size, + i_t max_combinations, + f_t* work_estimate, + f_t max_work_estimate, + f_t start_time = 0.0, + f_t time_limit = inf) +{ + cuopt_assert(max_combination_size > 0, "Maximum GF(2) combination size must be positive"); + cuopt_assert(max_combinations > 0, "Maximum number of GF(2) combinations must be positive"); + + i_t max_index = -1; + f_t input_scan_work = 0.0; + for (const auto& row : rows) { + input_scan_work += row.parity.size() + 1; + cuopt_assert(std::is_sorted(row.parity.begin(), row.parity.end()), + "GF(2) parity rows must be sorted"); + cuopt_assert(std::adjacent_find(row.parity.begin(), row.parity.end()) == row.parity.end(), + "GF(2) parity rows must not contain duplicates"); + if (!row.parity.empty()) { + cuopt_assert(row.parity.front() >= 0, "GF(2) parity index must be nonnegative"); + max_index = std::max(max_index, row.parity.back()); + } + } + if (add_work_estimate(input_scan_work, work_estimate, max_work_estimate)) { return {}; } + + std::vector permutation(rows.size()); + std::iota(permutation.begin(), permutation.end(), 0); + const f_t sort_work = permutation.size() * std::log2(permutation.size() + 1.0); + if (add_work_estimate(sort_work, work_estimate, max_work_estimate)) { return {}; } + // this is to process small/sparse rows first, for faster perf and smaller combinations + std::stable_sort(permutation.begin(), permutation.end(), mod2_row_order_t{rows}); + + if (add_work_estimate((f_t)(max_index + 1), work_estimate, max_work_estimate)) { return {}; } + std::vector pivot_to_basis((size_t)(max_index + 1), -1); + std::vector> basis; + basis.reserve(std::min(rows.size(), (size_t)(max_index + 1))); + std::vector> combinations; + combinations.reserve(std::min((size_t)max_combinations, rows.size())); + + std::vector parity_tmp; + std::vector combination_tmp; + for (const i_t candidate : permutation) { + if (toc(start_time) >= time_limit) { break; } + f_t candidate_work = rows[candidate].parity.size() + 2; + mod2_basis_row_t current; + current.parity = rows[candidate].parity; + current.combination = {candidate}; + current.rhs = rows[candidate].rhs_parity; + + bool abandoned = false; + while (!current.parity.empty()) { + const i_t pivot = current.parity.front(); + const i_t basis_index = pivot_to_basis[pivot]; + // pivot has not been seen before + if (basis_index < 0) { break; } + + const auto& pivot_row = basis[basis_index]; + candidate_work += current.parity.size() + pivot_row.parity.size() + + current.combination.size() + pivot_row.combination.size(); + symmetric_difference_sorted(current.parity, pivot_row.parity, parity_tmp); + symmetric_difference_sorted(current.combination, pivot_row.combination, combination_tmp); + if (combination_tmp.size() > (size_t)max_combination_size) { + abandoned = true; + break; + } + current.parity.swap(parity_tmp); + current.combination.swap(combination_tmp); + current.rhs = current.rhs != pivot_row.rhs; + } + if (add_work_estimate(candidate_work, work_estimate, max_work_estimate)) { break; } + if (abandoned) { continue; } + + // when reduced, add to combinations and continue, don't add to basis + if (current.parity.empty()) { + if (current.rhs && !current.combination.empty()) { + combinations.push_back(std::move(current.combination)); + if (combinations.size() >= (size_t)max_combinations) { break; } + } + continue; + } + + const i_t pivot = current.parity.front(); + pivot_to_basis[pivot] = basis.size(); + basis.push_back(std::move(current)); + } + return combinations; +} + +template +bool value_is_integral(f_t value, f_t tolerance) +{ + return std::abs(value - std::round(value)) <= tolerance; +} + +template +i_t mod2_integral_scale(const inequality_t& inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + i_t max_integral_scale, + f_t row_tight_tol, + f_t coefficient_integral_tol, + f_t start_time, + f_t time_limit, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached) +{ + for (i_t scale = 1; scale <= max_integral_scale; ++scale) { + if (toc(start_time) >= time_limit || work_limit_reached) { return i_t{0}; } + f_t scale_work = 1.0; + bool integral = true; + const f_t scaled_rhs = scale * inequality.rhs; + if (!value_is_integral(scaled_rhs, coefficient_integral_tol)) { integral = false; } + for (i_t k = 0; integral && k < (i_t)inequality.size(); ++k) { + scale_work += 1.0; + const i_t j = inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS || transformed_xstar[j] <= row_tight_tol) { + continue; + } + const f_t scaled_coefficient = scale * inequality.coeff(k); + if (!value_is_integral(scaled_coefficient, coefficient_integral_tol)) { integral = false; } + } + if (add_work_estimate(scale_work, &work_estimate, max_work_estimate, &work_limit_reached)) { + return i_t{0}; + } + if (integral) { return scale; } + } + return i_t{0}; +} + +template +std::vector> mod2_collect_candidates( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + const lp_problem_t& lp, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& transformed_xstar, + f_t start_time, + f_t time_limit, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached) +{ + constexpr i_t max_integral_scale = 1000; + const i_t max_integer_row_length = 1000 + lp.num_cols / 10; + constexpr f_t row_tight_tol = 1e-6; + constexpr f_t coefficient_integral_tol = 1e-6; + + std::vector> candidates; + candidates.reserve(lp.num_rows); + for (i_t row = 0; row < lp.num_rows; ++row) { + if (toc(start_time) >= time_limit || work_limit_reached) { break; } + const i_t slack = complemented_mir.slack_cols(row); + if (slack < 0 || transformed_xstar[slack] > row_tight_tol) { continue; } + + const i_t row_length = Arow.row_start[row + 1] - Arow.row_start[row]; + if (row_length > max_integer_row_length) { continue; } + const f_t row_work = (8 * row_length + 5) + row_length * std::log2(row_length + 1.0); + if (add_work_estimate(row_work, &work_estimate, max_work_estimate, &work_limit_reached)) { + break; + } + inequality_t inequality(Arow, row, lp.rhs[row]); + complemented_mir.transform_inequality(variable_bounds, var_types, inequality); + inequality.sort(); + + // Every LP row is an equality after slack insertion. Remove a zero-valued transformed slack + // in the direction that preserves a valid >= inequality. + i_t slack_position = -1; + for (i_t k = 0; k < (i_t)inequality.size(); ++k) { + if (inequality.index(k) == slack) { + slack_position = k; + break; + } + } + if (slack_position < 0 || inequality.coeff(slack_position) == 0.0) { continue; } + // we want a row that is a.x >= b + if (inequality.coeff(slack_position) > 0.0) { inequality.negate(); } + inequality.vector.x[slack_position] = 0.0; + inequality_t squeezed_inequality(lp.num_cols); + inequality.squeeze(squeezed_inequality); + inequality = std::move(squeezed_inequality); + + // Continuous variables must be at their selected bounds to participate in the parity system. + bool continuous_at_bounds = true; + for (i_t k = 0; k < (i_t)inequality.size(); ++k) { + const i_t j = inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS && + std::abs(inequality.coeff(k)) > coefficient_integral_tol && + transformed_xstar[j] > row_tight_tol) { + continuous_at_bounds = false; + break; + } + } + if (!continuous_at_bounds) { continue; } + + const i_t scale = mod2_integral_scale(inequality, + var_types, + transformed_xstar, + max_integral_scale, + row_tight_tol, + coefficient_integral_tol, + start_time, + time_limit, + work_estimate, + max_work_estimate, + work_limit_reached); + // no integral scale found or time limit reached + if (scale == 0) { continue; } + if (scale != 1) { inequality.scale(scale); } + + mod2_candidate_t candidate; + candidate.transformed_inequality = std::move(inequality); + candidate.rhs_parity = (std::abs(std::llround(candidate.transformed_inequality.rhs)) % 2) != 0; + // checks if this could be safely reversed + candidate.reversible = std::abs(lp.upper[slack] - lp.lower[slack]) <= row_tight_tol; + for (i_t k = 0; k < (i_t)candidate.transformed_inequality.size(); ++k) { + const i_t j = candidate.transformed_inequality.index(k); + if (var_types[j] == variable_type_t::CONTINUOUS || transformed_xstar[j] <= row_tight_tol) { + continue; + } + const auto coefficient = std::llround(candidate.transformed_inequality.coeff(k)); + if ((std::abs(coefficient) % 2) != 0) { candidate.parity.push_back(j); } + } + if (candidate.parity.size() > (size_t)max_integer_row_length) { continue; } + candidates.push_back(std::move(candidate)); + } + return candidates; +} + +template +void mod2_add_transformed_zero_half_cut( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + cut_pool_t& cut_pool, + const lp_problem_t& lp, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& xstar, + inequality_t transformed_cut, + f_t min_violation, + f_t& work_estimate, + i_t& cuts_added) +{ + work_estimate += 4 * transformed_cut.size() + 1; + complemented_mir.untransform_inequality(variable_bounds, var_types, transformed_cut); + complemented_mir.remove_small_coefficients(lp.lower, lp.upper, transformed_cut); + complemented_mir.substitute_slacks(lp, Arow, transformed_cut, &work_estimate); + complemented_mir.remove_small_coefficients(lp.lower, lp.upper, transformed_cut); + const f_t violation = complemented_mir.compute_violation(transformed_cut, xstar); + if (violation > min_violation) { + const i_t pool_size = cut_pool.pool_size(); + cut_pool.add_cut(cut_type_t::ZERO_HALF, transformed_cut); + if (cut_pool.pool_size() > pool_size) { ++cuts_added; } + } +} + +template +void mod2_generate_cuts_from_aggregate( + complemented_mixed_integer_rounding_cut_t& complemented_mir, + cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const variable_bounds_t& variable_bounds, + const std::vector& var_types, + const std::vector& xstar, + const std::vector& transformed_xstar, + const inequality_t& oriented_aggregate, + f_t min_violation, + f_t start_time, + f_t& work_estimate, + f_t max_work_estimate, + bool& work_limit_reached, + i_t& cuts_added) +{ + if (add_work_estimate((f_t)(3 * oriented_aggregate.size() + 1), + &work_estimate, + max_work_estimate, + &work_limit_reached)) { + return; + } + inequality_t mir_cut(lp.num_cols); + const bool mir_cut_generated = complemented_mir.generate_cut_nonnegative_maintain_indicies( + oriented_aggregate, var_types, mir_cut); + if (mir_cut_generated) { + mod2_add_transformed_zero_half_cut(complemented_mir, + cut_pool, + lp, + Arow, + variable_bounds, + var_types, + xstar, + std::move(mir_cut), + min_violation, + work_estimate, + cuts_added); + } + + if (work_estimate > max_work_estimate) { + work_limit_reached = true; + return; + } + inequality_t lifted_cover_cut(lp.num_cols); + bool lifted_cover_cut_generated = false; + if (toc(start_time) < settings.time_limit) { + lifted_cover_cut_generated = + complemented_mir.generate_lifted_mixed_binary_cover(oriented_aggregate, + var_types, + transformed_xstar, + lifted_cover_cut, + work_estimate, + max_work_estimate); + } + if (lifted_cover_cut_generated) { + mod2_add_transformed_zero_half_cut(complemented_mir, + cut_pool, + lp, + Arow, + variable_bounds, + var_types, + xstar, + std::move(lifted_cover_cut), + min_violation, + work_estimate, + cuts_added); + } + if (work_estimate > max_work_estimate) { work_limit_reached = true; } +} + +template +struct lifted_cover_order_t { + const std::vector& solution_value; + const inequality_t& base; + f_t tolerance; + + bool operator()(int a, int b) const + { + const bool a_at_upper = solution_value[a] >= 1.0 - tolerance; + const bool b_at_upper = solution_value[b] >= 1.0 - tolerance; + if (a_at_upper != b_at_upper) { return a_at_upper; } + const f_t contribution_a = solution_value[a] * base.coeff(a); + const f_t contribution_b = solution_value[b] * base.coeff(b); + if (contribution_a != contribution_b) { return contribution_a > contribution_b; } + return base.coeff(a) > base.coeff(b); + } +}; + +template +f_t lifted_cover_coefficient( + f_t coefficient, const std::vector& prefix, size_t p, f_t lambda, f_t tolerance) +{ + for (size_t h = 0; h < p; ++h) { + if (coefficient <= prefix[h] - lambda + tolerance) { return h * lambda; } + if (coefficient <= prefix[h] + tolerance) { return (h + 1) * lambda + coefficient - prefix[h]; } + } + return p * lambda + coefficient - prefix[p - 1]; +} + +} // namespace + +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations) +{ + return find_mod2_row_combinations_for_test(parity_rows, + rhs_parity, + max_combination_size, + max_combinations, + std::numeric_limits::infinity(), + nullptr); +} + +std::vector> find_mod2_row_combinations_for_test( + const std::vector>& parity_rows, + const std::vector& rhs_parity, + int max_combination_size, + int max_combinations, + double max_work_estimate, + double* work_estimate_out) +{ + cuopt_assert(parity_rows.size() == rhs_parity.size(), + "GF(2) parity row and rhs sizes must match"); + std::vector> rows; + rows.reserve(parity_rows.size()); + for (size_t i = 0; i < parity_rows.size(); ++i) { + rows.push_back({parity_rows[i], rhs_parity[i] != 0}); + } + + double work_estimate = 0.0; + auto combinations = find_mod2_row_combinations( + rows, max_combination_size, max_combinations, &work_estimate, max_work_estimate); + if (work_estimate_out != nullptr) { *work_estimate_out = work_estimate; } + return combinations; +} + +template +bool generate_mod2_zero_half_cuts(cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + f_t start_time, + f_t& work_estimate) +{ + constexpr i_t max_combination_size = 64; + constexpr i_t max_row_combinations = 1000; + constexpr f_t min_violation = 1e-6; + constexpr f_t candidate_work_limit = 3e7; + constexpr f_t combination_work_limit = 3e7; + constexpr f_t generation_work_limit = 4e7; + f_t candidate_work = 0.0; + f_t combination_work = 0.0; + f_t generation_work = 0.0; + bool candidate_limit_reached = false; + bool generation_limit_reached = false; + + if (add_work_estimate((f_t)(3 * lp.num_cols) + (f_t)(variable_bounds.upper_variables.size() + + variable_bounds.lower_variables.size()), + &candidate_work, + candidate_work_limit, + &candidate_limit_reached)) { + work_estimate = candidate_work; + return false; + } + complemented_mixed_integer_rounding_cut_t complemented_mir(lp, settings, new_slacks); + std::vector transformed_xstar; + complemented_mir.bound_substitution( + lp, variable_bounds, var_types, xstar, transformed_xstar, true); + + auto candidates = mod2_collect_candidates(complemented_mir, + lp, + Arow, + variable_bounds, + var_types, + transformed_xstar, + start_time, + settings.time_limit, + candidate_work, + candidate_work_limit, + candidate_limit_reached); + + if (toc(start_time) >= settings.time_limit) { + work_estimate = candidate_work; + return true; + } + auto row_combinations = find_mod2_row_combinations(candidates, + max_combination_size, + max_row_combinations, + &combination_work, + combination_work_limit, + start_time, + settings.time_limit); + if (add_work_estimate((f_t)(2 * lp.num_cols), + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + work_estimate = candidate_work + combination_work + generation_work; + return true; + } + scratch_pad_t aggregate_pad(lp.num_cols); + + for (const auto& combination : row_combinations) { + if (toc(start_time) >= settings.time_limit || generation_limit_reached) { break; } + + size_t aggregate_input_nz = 0; + for (const i_t candidate_index : combination) { + aggregate_input_nz += candidates[candidate_index].transformed_inequality.size(); + } + if (add_work_estimate((f_t)(2 * aggregate_input_nz + 1), + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + break; + } + + inequality_t aggregate(lp.num_cols); + bool reversible = true; + for (const i_t candidate_index : combination) { + const auto& candidate = candidates[candidate_index]; + aggregate.rhs += candidate.transformed_inequality.rhs; + reversible = reversible && candidate.reversible; + for (i_t k = 0; k < (i_t)candidate.transformed_inequality.size(); ++k) { + aggregate_pad.add_to_pad(candidate.transformed_inequality.index(k), + candidate.transformed_inequality.coeff(k)); + } + } + aggregate_pad.get_pad(aggregate.vector.i, aggregate.vector.x); + aggregate_pad.clear_pad(); + const f_t aggregate_output_work = + 3 * aggregate.size() + aggregate.size() * std::log2(aggregate.size() + 1.0); + if (add_work_estimate(aggregate_output_work, + &generation_work, + generation_work_limit, + &generation_limit_reached)) { + break; + } + aggregate.sort(); + aggregate.scale(0.5); + + i_t cuts_added = 0; + mod2_generate_cuts_from_aggregate(complemented_mir, + cut_pool, + lp, + settings, + Arow, + variable_bounds, + var_types, + xstar, + transformed_xstar, + aggregate, + min_violation, + start_time, + generation_work, + generation_work_limit, + generation_limit_reached, + cuts_added); + // if the final inequality is reversable, try the reversed version as well + if (reversible && toc(start_time) < settings.time_limit && !generation_limit_reached) { + aggregate.negate(); + mod2_generate_cuts_from_aggregate(complemented_mir, + cut_pool, + lp, + settings, + Arow, + variable_bounds, + var_types, + xstar, + transformed_xstar, + aggregate, + min_violation, + start_time, + generation_work, + generation_work_limit, + generation_limit_reached, + cuts_added); + } + } + work_estimate = candidate_work + combination_work + generation_work; + return true; +} + +template +bool complemented_mixed_integer_rounding_cut_t::generate_lifted_mixed_binary_cover( + const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + f_t& work_estimate, + f_t max_work_estimate) +{ + constexpr f_t tolerance = 1e-6; + + const f_t estimated_work = + 12 * transformed_inequality.size() + + transformed_inequality.size() * std::log2(transformed_inequality.size() + 1.0); + if (add_work_estimate(estimated_work, &work_estimate, max_work_estimate)) { return false; } + + inequality_t base = transformed_inequality; + base.negate(); + + std::vector locally_complemented(base.size(), 0); + std::vector solution_value(base.size(), 0.0); + std::vector is_integral(base.size(), 0); + for (i_t k = 0; k < (i_t)base.size(); ++k) { + const i_t j = base.index(k); + f_t aj = base.coeff(k); + if (var_types[j] == variable_type_t::CONTINUOUS) { + solution_value[k] = transformed_xstar[j]; + if (aj > 0.0) { base.vector.x[k] = 0.0; } + continue; + } + + const f_t upper = new_upper(j); + if (upper == inf || std::abs(upper - 1.0) > tolerance) { return false; } + is_integral[k] = 1; + if (aj < 0.0) { + base.rhs -= aj * upper; + base.vector.x[k] = -aj; + solution_value[k] = upper - transformed_xstar[j]; + locally_complemented[k] = 1; + } else { + solution_value[k] = transformed_xstar[j]; + } + } + + std::vector cover; + cover.reserve(base.size()); + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (is_integral[k] && base.coeff(k) > tolerance && solution_value[k] > tolerance) { + cover.push_back(k); + } + } + if (cover.empty()) { return false; } + + std::stable_sort( + cover.begin(), cover.end(), lifted_cover_order_t{solution_value, base, tolerance}); + + f_t cover_weight = 0.0; + size_t cover_size = 0; + for (; cover_size < cover.size(); ++cover_size) { + cover_weight += base.coeff(cover[cover_size]); + if (cover_weight - base.rhs > tolerance * std::max((f_t)1.0, std::abs(base.rhs))) { + ++cover_size; + break; + } + } + if (cover_size == 0 || cover_size > cover.size()) { return false; } + cover.resize(cover_size); + + const f_t lambda = cover_weight - base.rhs; + if (lambda <= tolerance) { return false; } + std::sort( + cover.begin(), cover.end(), [&](i_t a, i_t b) { return base.coeff(a) > base.coeff(b); }); + + std::vector prefix(cover.size(), 0.0); + std::vector in_cover(base.size(), 0); + f_t prefix_sum = 0.0; + size_t p = cover.size(); + for (size_t h = 0; h < cover.size(); ++h) { + const i_t k = cover[h]; + in_cover[k] = 1; + if (base.coeff(k) - lambda <= tolerance && p == cover.size()) { p = h; } + if (h < p) { + prefix_sum += base.coeff(k); + prefix[h] = prefix_sum; + } + } + if (p == 0) { return false; } + + size_t non_cover_count = 0; + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (is_integral[k] && !in_cover[k]) { non_cover_count++; } + } + if (add_work_estimate((f_t)(non_cover_count * p), &work_estimate, max_work_estimate)) { + return false; + } + + transformed_cut = base; + transformed_cut.rhs = -lambda; + for (i_t k = 0; k < (i_t)base.size(); ++k) { + if (!is_integral[k]) { + if (base.coeff(k) >= 0.0) { transformed_cut.vector.x[k] = 0.0; } + continue; + } + if (in_cover[k]) { + transformed_cut.vector.x[k] = std::min(base.coeff(k), lambda); + transformed_cut.rhs += transformed_cut.coeff(k); + } else { + transformed_cut.vector.x[k] = + lifted_cover_coefficient(base.coeff(k), prefix, p, lambda, tolerance); + } + } + + for (i_t k = 0; k < (i_t)transformed_cut.size(); ++k) { + if (!locally_complemented[k]) { continue; } + const i_t j = transformed_cut.index(k); + const f_t coefficient = transformed_cut.coeff(k); + transformed_cut.rhs -= coefficient * new_upper(j); + transformed_cut.vector.x[k] = -coefficient; + } + inequality_t squeezed_cut(transformed_cut.vector.n); + transformed_cut.squeeze(squeezed_cut); + transformed_cut = std::move(squeezed_cut); + transformed_cut.negate(); + return true; +} + +#ifdef DUAL_SIMPLEX_INSTANTIATE_DOUBLE +template bool generate_mod2_zero_half_cuts( + cut_pool_t& cut_pool, + const lp_problem_t& lp, + const simplex_solver_settings_t& settings, + csr_matrix_t& Arow, + const std::vector& new_slacks, + const std::vector& var_types, + const std::vector& xstar, + variable_bounds_t& variable_bounds, + double start_time, + double& work_estimate); + +template bool +complemented_mixed_integer_rounding_cut_t::generate_lifted_mixed_binary_cover( + const inequality_t& transformed_inequality, + const std::vector& var_types, + const std::vector& transformed_xstar, + inequality_t& transformed_cut, + double& work_estimate, + double max_work_estimate); +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/dual_simplex/solve.cpp b/cpp/src/dual_simplex/solve.cpp index 7907abd3b9..388bb43b35 100644 --- a/cpp/src/dual_simplex/solve.cpp +++ b/cpp/src/dual_simplex/solve.cpp @@ -104,6 +104,12 @@ f_t compute_user_objective(const lp_problem_t& lp, f_t obj) return user_obj; } +template +f_t compute_presolved_objective(const lp_problem_t& lp, f_t user_obj) +{ + return user_obj / lp.obj_scale - lp.obj_constant; +} + template lp_status_t solve_linear_program_advanced(const lp_problem_t& original_lp, const f_t start_time, @@ -813,6 +819,8 @@ template double compute_user_objective(const lp_problem_t& lp, double obj); +template double compute_presolved_objective(const lp_problem_t& lp, double user_obj); + template lp_status_t solve_linear_program_advanced( const lp_problem_t& original_lp, const double start_time, diff --git a/cpp/src/dual_simplex/solve.hpp b/cpp/src/dual_simplex/solve.hpp index 90c2dbd690..308c462de5 100644 --- a/cpp/src/dual_simplex/solve.hpp +++ b/cpp/src/dual_simplex/solve.hpp @@ -63,6 +63,9 @@ f_t compute_user_objective(const lp_problem_t& lp, const std::vector f_t compute_user_objective(const lp_problem_t& lp, f_t obj); +template +f_t compute_presolved_objective(const lp_problem_t& lp, f_t user_obj); + template lp_status_t solve_linear_program_advanced(const lp_problem_t& original_lp, const f_t start_time, diff --git a/cpp/src/io/mps_writer.cpp b/cpp/src/io/mps_writer.cpp index 31a0bbc25c..bb769aceb1 100644 --- a/cpp/src/io/mps_writer.cpp +++ b/cpp/src/io/mps_writer.cpp @@ -229,8 +229,8 @@ void mps_writer_t::write(const std::string& mps_file_path) // save coefficients with full precision mps_file << std::setprecision(std::numeric_limits::max_digits10); - // NAME section - mps_file << "NAME " << problem_.get_problem_name() << "\n"; + const std::string& pname = problem_.get_problem_name(); + mps_file << "NAME " << (pname.empty() ? "cuopt" : pname) << "\n"; if (problem_.get_sense()) { mps_file << "OBJSENSE\n MAXIMIZE\n"; } diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 2a193cd70b..820f41ee3e 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -122,6 +122,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_SUBMIP_MIN_FIXRATE_CAP, &mip_settings.submip_params.min_fixrate_cap, f_t(0.0), f_t(1.0), f_t(0.1), "hard cap on the minimum fix rate for solving a sub-MIP"}, {CUOPT_MIP_HYPER_SUBMIP_TARGET_MIP_GAP, &mip_settings.submip_params.target_mip_gap, f_t(0.0), f_t(1.0), f_t(0.01), "MIP gap target for the sub-MIP"}, {CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_RATIO, &mip_settings.submip_params.iteration_limit_ratio, f_t(0.0), f_t(1.0), f_t(0.8), "sub-MIP simplex-iteration limit as a factor of parent B&B iterations"}, + {CUOPT_MIP_HYPER_SUBMIP_ROUND_CLOSE_RATIO, &mip_settings.submip_params.round_close_ratio, f_t(0.0), f_t(1.0), f_t(0.8), "share of the still-unfixed integers left for later neighbourhood rounds (0 reaches the target fix rate in a single round)"}, }; // Int parameters @@ -149,6 +150,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_STRONG_CHVATAL_GOMORY_CUTS, &mip_settings.strong_chvatal_gomory_cuts, -1, 1, -1}, {CUOPT_MIP_REDUCED_COST_STRENGTHENING, &mip_settings.reduced_cost_strengthening, -1, std::numeric_limits::max(), -1}, {CUOPT_MIP_RINS, &mip_settings.submip_params.rins, -1, 1, -1}, + {CUOPT_MIP_RENS, &mip_settings.submip_params.rens, -1, 1, -1}, {CUOPT_MIP_OBJECTIVE_STEP, &mip_settings.objective_step, 0, 1, 1}, {CUOPT_NUM_GPUS, &pdlp_settings.num_gpus, -1, 72, 1}, {CUOPT_NUM_GPUS, &mip_settings.num_gpus, -1, 72, 1}, @@ -185,7 +187,8 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_DIVING_NODE_LIMIT, &mip_settings.diving_params.node_limit, 0, std::numeric_limits::max(), 500, "maximum nodes explored per dive"}, {CUOPT_MIP_HYPER_DIVING_BACKTRACK_LIMIT, &mip_settings.diving_params.backtrack_limit, 0, std::numeric_limits::max(), 5, "maximum backtracking allowed per dive"}, // Recursive sub-MIP (RINS) hyper-parameters (hidden from default --help: name contains "hyper_") - {CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_BASE, &mip_settings.submip_params.node_limit_base, 0, std::numeric_limits::max(), 200, "base node limit for the sub-MIP"}, + {CUOPT_MIP_HYPER_SUBMIP_NODE_LIMIT_OFFSET, &mip_settings.submip_params.node_limit_offset, 0, std::numeric_limits::max(), 200, "base node limit for the sub-MIP"}, + {CUOPT_MIP_HYPER_SUBMIP_ITERATION_LIMIT_OFFSET, &mip_settings.submip_params.iteration_limit_offset, 0, std::numeric_limits::max(), 10000, "base sub-MIP simplex-iteration limit for root heuristics"}, {CUOPT_MIP_HYPER_SUBMIP_MAX_LEVEL, &mip_settings.submip_params.max_level, 0, std::numeric_limits::max(), 10, "maximum sub-MIP recursion level"}, {CUOPT_BARRIER_PRESOLVE_BOUND_FREE_VARIABLES, &pdlp_settings.barrier_presolve_bound_free_variables, -1, 1, -1, "Bound free variables during barrier presolve: -1 automatic (current default behavior), 0 disabled, 1 enabled"}, // QCQP (barrier) scaling hyper-parameter @@ -213,6 +216,7 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_HYPER_DIVING_SHOW_TYPE, &mip_settings.diving_params.show_type, false, "log diving heuristic type when it finds a new incumbent"}, // Recursive sub-MIP (RINS) hyper-parameters (hidden from default --help: name contains "hyper_") {CUOPT_MIP_HYPER_SUBMIP_ENABLE_CPUFJ, &mip_settings.submip_params.enable_cpufj, true, "run CPU FJ over the sub-MIP"}, + {CUOPT_MIP_HYPER_BLOCK_BVE, &mip_settings.block_bve, true, "eliminate blocks of binaries in cuOpt's MIP presolve (needs " CUOPT_MIP_PROBING ")"}, }; // String parameters string_parameters = { diff --git a/cpp/src/mip_heuristics/CMakeLists.txt b/cpp/src/mip_heuristics/CMakeLists.txt index 7705465512..a35cdd7e4f 100644 --- a/cpp/src/mip_heuristics/CMakeLists.txt +++ b/cpp/src/mip_heuristics/CMakeLists.txt @@ -13,6 +13,7 @@ set(MIP_LP_NECESSARY_FILES ${CMAKE_CURRENT_SOURCE_DIR}/local_search/rounding/simple_rounding.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/third_party_presolve.cpp ${CMAKE_CURRENT_SOURCE_DIR}/presolve/gf2_presolve.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bhw_coeff_reduce.cpp ${CMAKE_CURRENT_SOURCE_DIR}/solution/solution.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/conflict_graph/clique_table.cu ) @@ -33,6 +34,7 @@ set(MIP_NON_LP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/local_search/rounding/simple_rounding.cu ${CMAKE_CURRENT_SOURCE_DIR}/local_search/feasibility_pump/feasibility_pump.cu ${CMAKE_CURRENT_SOURCE_DIR}/local_search/line_segment_search/line_segment_search.cu + ${CMAKE_CURRENT_SOURCE_DIR}/presolve/block_bve.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bounds_presolve.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/bounds_update_data.cu ${CMAKE_CURRENT_SOURCE_DIR}/presolve/semi_continuous.cu @@ -43,6 +45,8 @@ set(MIP_NON_LP_FILES ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/feasibility_jump.cu ${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/fj_cpu_binary.cu + ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/fj_cpu_binary_kernels.cpp ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_cpufj.cu ${CMAKE_CURRENT_SOURCE_DIR}/feasibility_jump/early_gpufj.cu) diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index 9d70ae17ee..e69022c42d 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -8,9 +8,11 @@ #include "cuda_profiler_api.h" #include "diversity_manager.cuh" +#include #include #include +#include #include #include #include @@ -18,8 +20,12 @@ #include +#include #include +#include + +#include #include #include #include @@ -307,8 +313,18 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ CUOPT_LOG_INFO("Probing-cache step disabled via %s=false", CUOPT_MIP_PROBING); run_probing_cache = false; } - if (run_probing_cache) { + const bool remap_cache_ids = true; + problem_ptr->related_vars_time_limit = context.settings.heuristic_params.related_vars_time_limit; + + if (run_probing_cache && !global_timer.check_time_limit() && !presolve_timer.check_time_limit()) { log_presolve_budget("PROBING", probing_features, probing_budget); + // The early CPUFJ lanes hold their threads for the whole of presolve, and probing's default + // task count assumes the whole team. Its pools are sized per task, so this bounds host memory + // as well as concurrency. + const i_t held_by_cpufj = + context.early_cpufj_ptr != nullptr ? (i_t)context.early_cpufj_ptr->lane_count() : 0; + ls.constraint_prop.bounds_update.settings.num_tasks = + std::max(1, omp_get_num_threads() - 1 - held_by_cpufj); f_t time_for_probing_cache = std::min(time_limit, (f_t)global_timer.remaining_time()); timer_t probing_timer{time_for_probing_cache}; [[maybe_unused]] const auto probing_t0 = std::chrono::steady_clock::now(); @@ -324,9 +340,17 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ std::chrono::duration(std::chrono::steady_clock::now() - probing_t0).count()); if (problem_is_infeasible) { return false; } } - const bool remap_cache_ids = true; - problem_ptr->related_vars_time_limit = context.settings.heuristic_params.related_vars_time_limit; + if (!global_timer.check_time_limit()) { trivial_presolve(*problem_ptr, remap_cache_ids); } + + if (context.settings.block_bve && run_probing_cache) { + timer_t bve_deadline(std::min(global_timer.remaining_time(), presolve_timer.remaining_time())); + if (!block_bve_phase(ls.constraint_prop.bounds_update, *problem_ptr, bve_deadline)) { + stats.presolve_time = timer.elapsed_time(); + return false; + } + } + if (!problem_ptr->empty && !check_bounds_sanity(*problem_ptr)) { return false; } // if (!presolve_timer.check_time_limit() && !context.settings.heuristics_only && // !problem_ptr->empty) { diff --git a/cpp/src/mip_heuristics/diversity/population.cu b/cpp/src/mip_heuristics/diversity/population.cu index 553e5d6e93..033119915f 100644 --- a/cpp/src/mip_heuristics/diversity/population.cu +++ b/cpp/src/mip_heuristics/diversity/population.cu @@ -233,6 +233,12 @@ std::vector> population_t::get_external_solutions sol.compute_number_of_integers(), problem_ptr->n_integer_vars); } + if (std::abs(sol.get_objective() - h_entry.objective) > OBJECTIVE_EPSILON) { + CUOPT_LOG_DEBUG( + "External solution objective mismatch: sol.get_objective() = %g, h_entry.objective = %g", + sol.get_objective(), + h_entry.objective); + } sol.handle_ptr->sync_stream(); return_vector.emplace_back(std::move(sol)); counter++; @@ -258,41 +264,6 @@ bool population_t::is_better_than_best_feasible(solution_t& return obj_better && sol.get_feasible(); } -template -void population_t::invoke_get_solution_callback( - solution_t& sol, internals::get_solution_callback_t* callback) -{ - f_t user_objective = sol.get_user_objective(); - f_t user_bound = context.stats.get_solution_bound(); - solution_t temp_sol(sol); - problem_ptr->post_process_assignment(temp_sol.assignment); - if (problem_ptr->has_papilo_presolve_data()) { - problem_ptr->papilo_uncrush_assignment(temp_sol.assignment); - } - - std::vector user_objective_vec(1); - std::vector user_bound_vec(1); - std::vector user_assignment_vec(temp_sol.assignment.size()); - user_objective_vec[0] = user_objective; - user_bound_vec[0] = user_bound; - raft::copy(user_assignment_vec.data(), - temp_sol.assignment.data(), - temp_sol.assignment.size(), - temp_sol.handle_ptr->get_stream()); - temp_sol.handle_ptr->sync_stream(); - if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( - context.settings)) { - mip::strip_semi_continuous_auxiliaries_from_assignment( - user_assignment_vec, - mip_solver_settings_accessor::get_semi_continuous_original_num_variables( - context.settings)); - } - callback->get_solution(user_assignment_vec.data(), - user_objective_vec.data(), - user_bound_vec.data(), - callback->get_user_data()); -} - template void population_t::run_solution_callbacks(solution_t& sol) { @@ -303,15 +274,14 @@ void population_t::run_solution_callbacks(solution_t& sol) context.settings.benchmark_info_ptr->last_improvement_of_best_feasible = timer.elapsed_time(); } CUOPT_LOG_DEBUG("Population: Found new best solution %g", sol.get_user_objective()); - if (problem_ptr->branch_and_bound_callback != nullptr) { - problem_ptr->branch_and_bound_callback(sol.get_host_assignment(), - heuristics_origin_t::HEURISTICS); - } - for (auto callback : user_callbacks) { - if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { - auto get_sol_callback = static_cast(callback); - invoke_get_solution_callback(sol, get_sol_callback); + if (problem_ptr->branch_and_bound_callback != nullptr || + context.solution_publication.enabled()) { + auto host_assignment = sol.get_host_assignment(); + if (problem_ptr->branch_and_bound_callback != nullptr) { + problem_ptr->branch_and_bound_callback(host_assignment, heuristics_origin_t::HEURISTICS); } + context.solution_publication.publish_if_better( + problem_ptr, host_assignment, sol.get_objective()); } // Save the best objective here even if callback handling later exits early. // This prevents older solutions from being reported as "new best" in subsequent callbacks. diff --git a/cpp/src/mip_heuristics/diversity/population.cuh b/cpp/src/mip_heuristics/diversity/population.cuh index 593b1ddf1e..5a9db26928 100644 --- a/cpp/src/mip_heuristics/diversity/population.cuh +++ b/cpp/src/mip_heuristics/diversity/population.cuh @@ -160,9 +160,6 @@ class population_t { void diversity_step(i_t max_iterations_without_improvement); - void invoke_get_solution_callback(solution_t& sol, - internals::get_solution_callback_t* callback); - // does some consistency tests bool test_invariant(); diff --git a/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh b/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh index 474becef25..95e5c45a8c 100644 --- a/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh +++ b/cpp/src/mip_heuristics/diversity/recombiners/sub_mip.cuh @@ -114,6 +114,7 @@ class sub_mip_recombiner_t : public recombiner_t { branch_and_bound_settings.zero_half_cuts = 0; branch_and_bound_settings.inside_submip = 1; branch_and_bound_settings.submip_settings.rins = 0; + branch_and_bound_settings.submip_settings.rens = 0; branch_and_bound_settings.strong_branching_simplex_iteration_limit = 200; branch_and_bound_settings.solution_callback = [this](std::vector& solution, f_t objective) { diff --git a/cpp/src/mip_heuristics/early_heuristic.cuh b/cpp/src/mip_heuristics/early_heuristic.cuh index 6654470732..84d5f86f7c 100644 --- a/cpp/src/mip_heuristics/early_heuristic.cuh +++ b/cpp/src/mip_heuristics/early_heuristic.cuh @@ -7,18 +7,13 @@ #pragma once -#include -#include - #include - -#include - -#include +#include #include #include #include +#include #include namespace cuopt::mathematical_optimization::mip { @@ -34,25 +29,13 @@ template 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) - : incumbent_callback_(std::move(incumbent_callback)) + : objective_scaling_factor_(op_problem.get_sense() ? -op_problem.get_objective_scaling_factor() + : op_problem.get_objective_scaling_factor()), + objective_offset_(op_problem.get_sense() ? -op_problem.get_objective_offset() + : op_problem.get_objective_offset()), + 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,12 +43,12 @@ class early_heuristic_t { // Return the best objective converted to user-space (sense-aware, offset-aware). f_t get_best_user_objective() const { - return problem_ptr_->get_user_obj_from_solver_obj(best_objective_); + return objective_scaling_factor_ * (best_objective_ + objective_offset_); } // Set the incumbent threshold. `obj` must be in THIS heuristic's solver-space - // (i.e. the space of problem_ptr_). Callers that hold a value from a different - // problem representation (e.g., the original pre-presolve problem) must convert - // it first, otherwise try_update_best will reject valid solutions. + // (i.e. the space of its input problem). Callers that hold a value from a + // different problem representation (e.g., the original pre-presolve problem) + // must convert it first, otherwise try_update_best will reject valid solutions. void set_best_objective(f_t obj) { best_objective_ = obj; } const std::vector& get_best_assignment() const { return best_assignment_; } @@ -73,40 +56,25 @@ class early_heuristic_t { ~early_heuristic_t() = default; // 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) { if (solver_obj >= best_objective_) { return; } best_objective_ = solver_obj; - RAFT_CUDA_TRY(cudaSetDevice(device_id_)); - auto stream = handle_.get_stream(); - rmm::device_uvector d_assignment(assignment.size(), stream); - raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); - problem_ptr_->post_process_assignment(d_assignment, true, stream); - auto user_assignment = cuopt::host_copy(d_assignment, stream); - - best_assignment_ = user_assignment; + best_assignment_ = ((Derived*)this)->to_user_assignment(assignment); solution_found_ = true; - f_t user_obj = problem_ptr_->get_user_obj_from_solver_obj(solver_obj); + f_t user_obj = get_best_user_objective(); // 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, best_assignment_, Derived::name()); } } - int device_id_{0}; - - // handle_ must be declared before problem_ptr_/solution_ptr_ so it outlives them - // (C++ destroys members in reverse declaration order) - raft::handle_t handle_; - - std::unique_ptr> problem_ptr_; - std::unique_ptr> solution_ptr_; - bool solution_found_{false}; f_t best_objective_{std::numeric_limits::infinity()}; + f_t objective_scaling_factor_; + f_t objective_offset_; std::vector best_assignment_; early_incumbent_callback_t incumbent_callback_; diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu index ba14e657d5..5f9a68ac99 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cu @@ -8,6 +8,10 @@ #include "early_cpufj.cuh" #include +#include + +#include +#include namespace cuopt::mathematical_optimization::mip { @@ -16,8 +20,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>(op_problem, std::move(incumbent_callback)), + problem_ptr_(&op_problem), + tolerances_(tolerances) { } @@ -28,43 +33,90 @@ early_cpufj_t::~early_cpufj_t() } template -void early_cpufj_t::start() +void early_cpufj_t::start(int n_lanes) { // 1: presolve, 1: early GPU FJ, 1: early CPU FJ - if (fj_cpu_ || omp_get_num_threads() < CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT) { return; } + if (!climbers_.empty() || omp_get_num_threads() < CUOPT_MIP_EARLY_CPUFJ_REQUIRED_THREAD_COUNT) { + return; + } this->preemption_flag_.store(false); this->start_time_ = std::chrono::steady_clock::now(); - fj_cpu_ = init_fj_cpu_standalone(*this->problem_ptr_, *this->solution_ptr_, preemption_flag_); - - fj_cpu_->log_prefix = "[Early CPUFJ] "; - - fj_cpu_->improvement_callback = [this](f_t solver_obj, - const std::vector& assignment, - double) { this->try_update_best(solver_obj, assignment); }; - - CUOPT_LOG_DEBUG("Launching early CPUFJ task"); -#pragma omp task shared(fj_cpu_) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ - depend(out : *fj_cpu_) default(none) - cpufj_solve(fj_cpu_.get()); + // Tasks are not preempted, so a lane posted beyond the team size would sit in the queue for the + // whole of presolve without running an iteration. + n_lanes = std::clamp(n_lanes, 1, omp_get_num_threads()); + const int64_t base_seed = cuopt::seed_generator::get_seed(); + climbers_.resize(n_lanes); + + auto report_incumbent = [this](f_t solver_obj, const std::vector& assignment, double) { + std::lock_guard guard(incumbent_mutex_); + this->try_update_best(solver_obj, assignment); + }; + + // Lane 0 builds the host problem representation and every other lane copies it. All of it + // finishes before the first task is posted, so no lane reads a template another lane is running + // on. seed_generator steps a non-atomic global, which is why the draws stay on this thread. + for (int k = 0; k < n_lanes; ++k) { + if (k == 0) { + climbers_[0] = + init_fj_cpu_from_optimization_problem(*this->problem_ptr_, tolerances_, preemption_flag_); + } else { + fj_settings_t settings; + settings.seed = (int)cuopt::seed_generator::get_seed(); + climbers_[k] = init_fj_cpu_clone(*climbers_[0], preemption_flag_, settings); + } + apply_lane_diversification(*climbers_[k], k, base_seed); + climbers_[k]->log_prefix = "[Early CPUFJ " + std::to_string(k) + "] "; + climbers_[k]->improvement_callback = report_incumbent; + } + + auto shared = std::make_shared>(); + for (int k = 0; k < n_lanes; ++k) + climbers_[k]->shared_incumbent = shared; + + CUOPT_LOG_DEBUG("Launching %d early CPUFJ tasks", n_lanes); + for (int k = 0; k < n_lanes; ++k) { + auto* climber = climbers_[k].get(); +#pragma omp task firstprivate(climber) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ + depend(out : *climber) default(none) + cpufj_solve(climber); + } } template void early_cpufj_t::stop() { - if (!fj_cpu_) { return; } + if (climbers_.empty()) { return; } preemption_flag_.store(true); - fj_cpu_->halted = true; -#pragma omp taskwait depend(in : *fj_cpu_) // Wait for the early CPUFJ task to finish - - CUOPT_LOG_DEBUG("[Early CPUFJ] Stopped after %d iterations, solution_found=%d", - fj_cpu_ ? fj_cpu_->iterations : 0, + // Every lane is told to stop before any wait, otherwise the first wait blocks on a lane that has + // not been asked to exit yet. + for (auto& climber : climbers_) { + climber->halted = true; + } + for (size_t k = 0; k < climbers_.size(); ++k) { +#pragma omp taskwait depend(in : *climbers_[k]) // Wait for each early CPUFJ task to finish + } + + i_t total_iterations = 0; + for (const auto& climber : climbers_) { + total_iterations += climber->iterations; + } + + CUOPT_LOG_DEBUG("[Early CPUFJ] Stopped after %d iterations over %d climbers, solution_found=%d", + total_iterations, + (int)climbers_.size(), this->solution_found_); - fj_cpu_.reset(); + climbers_.clear(); +} + +template +std::vector early_cpufj_t::to_user_assignment(const std::vector& assignment) +{ + return assignment; } #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh index e2bb2c07b2..3bae5ed63b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_cpufj.cuh @@ -12,6 +12,8 @@ #include #include +#include +#include namespace cuopt::mathematical_optimization::mip { @@ -26,12 +28,25 @@ class early_cpufj_t : public early_heuristic_t static constexpr const char* name() { return "CPUFJ"; } - void start(); + // Lanes are OMP tasks that never yield, so n_lanes threads are unavailable to anything else + // until stop(). Callers sharing the team with other work size it accordingly. + void start(int n_lanes); void stop(); + int lane_count() const { return (int)climbers_.size(); } + private: - std::unique_ptr> fj_cpu_; + friend class early_heuristic_t>; + + std::vector to_user_assignment(const std::vector& assignment); + + const optimization_problem_t* problem_ptr_; + typename mip_solver_settings_t::tolerances_t tolerances_; + std::vector>> climbers_; std::atomic preemption_flag_{false}; + // try_update_best and the incumbent callback behind it are not thread-safe, and every lane + // reports into them from its own task. + std::mutex incumbent_mutex_; }; } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu index 697f26e6df..c9d787a236 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cu @@ -10,9 +10,15 @@ #include #include #include +#include #include +#include #include +#include + +#include +#include #include @@ -22,11 +28,26 @@ 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>(op_problem, std::move(incumbent_callback)) { - context_ptr_ = std::make_unique>( - &this->handle_, this->problem_ptr_.get(), settings); + 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, settings.get_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(); + + context_ptr_ = + std::make_unique>(&handle_, problem_ptr_.get(), settings); } template @@ -63,7 +84,7 @@ void early_gpufj_t::start() #pragma omp task default(none) shared(fj_ptr_) priority(CUOPT_DEFAULT_TASK_PRIORITY) \ depend(out : *fj_ptr_) { - RAFT_CUDA_TRY(cudaSetDevice(this->device_id_)); + raft::device_setter guard(this->device_id_); fj_ptr_->solve(*this->solution_ptr_); } } @@ -81,6 +102,18 @@ void early_gpufj_t::stop() fj_ptr_.reset(); } +template +std::vector early_gpufj_t::to_user_assignment(const std::vector& assignment) +{ + // Uses a private CUDA stream to avoid racing with the FJ solver's stream. + RAFT_CUDA_TRY(cudaSetDevice(device_id_)); + auto stream = handle_.get_stream(); + rmm::device_uvector d_assignment(assignment.size(), stream); + raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); + problem_ptr_->post_process_assignment(d_assignment, true, stream); + return cuopt::host_copy(d_assignment, stream); +} + #if MIP_INSTANTIATE_FLOAT template class early_gpufj_t; #endif diff --git a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh index 99e8579d31..ed8d17206e 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/early_gpufj.cuh @@ -8,8 +8,11 @@ #pragma once #include +#include +#include #include +#include namespace cuopt::mathematical_optimization::mip { @@ -34,6 +37,18 @@ class early_gpufj_t : public early_heuristic_t void stop(); private: + friend class early_heuristic_t>; + + std::vector to_user_assignment(const std::vector& assignment); + + int device_id_{0}; + + // handle_ must be declared before problem_ptr_/solution_ptr_ so it outlives them + // (C++ destroys members in reverse declaration order) + raft::handle_t handle_; + + std::unique_ptr> problem_ptr_; + std::unique_ptr> solution_ptr_; std::unique_ptr> context_ptr_; std::unique_ptr> fj_ptr_; }; diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh index 8d1f39ce22..437dfa3a1b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump.cuh @@ -75,6 +75,10 @@ struct fj_hyper_parameters_t { double small_move_tabu_threshold = 1e-6; int small_move_tabu_tenure = 4; + int two_opt_max_rows = 4; + int two_opt_max_row_vars = 256; + int two_opt_max_pairs = 256; + // load-balancing related settings int old_codepath_total_var_to_relvar_ratio_threshold = 200; int load_balancing_codepath_min_varcount = 3200; @@ -198,6 +202,9 @@ struct fj_move_candidate_t { template struct fj_cpu_climber_t; +template +class probing_cache_t; + template class fj_t { public: @@ -215,6 +222,7 @@ class fj_t { const std::vector& right_weights, f_t objective_weight, std::atomic& preemption_flag, + const probing_cache_t* probing_cache, fj_settings_t settings = fj_settings_t{}, bool randomize_params = false); i_t alloc_max_climbers(i_t desired_climbers); @@ -521,12 +529,14 @@ class fj_t { HDI f_t lower_excess_score(i_t cstr, f_t lhs, f_t c_lb) const { - return raft::min(lhs - c_lb, (f_t)0); + const f_t excess = lhs - c_lb; + return excess < (f_t)0 ? excess : (f_t)0; } HDI f_t upper_excess_score(i_t cstr, f_t lhs, f_t c_ub) const { - return raft::min(c_ub - lhs, (f_t)0); + const f_t excess = c_ub - lhs; + return excess < (f_t)0 ? excess : (f_t)0; } // Computes the constraint's contribution to the feasibility score: @@ -556,7 +566,8 @@ class fj_t { { f_t cstr_tolerance = get_cstr_tolerance( c_lb, c_ub, pb.tolerances.absolute_tolerance, pb.tolerances.relative_tolerance); - return max((f_t)1e-12, cstr_tolerance - MACHINE_EPSILON); + const f_t corrected = cstr_tolerance - MACHINE_EPSILON; + return corrected > (f_t)1e-12 ? corrected : (f_t)1e-12; } HDI f_t get_corrected_tolerance(i_t cstr) const { diff --git a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh index 98267f117c..6535794e08 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/feasibility_jump_impl_common.cuh @@ -171,7 +171,7 @@ HDI std::pair feas_score_constraint( base_feas += (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } // simple worsening - else if (!old_sat && !new_sat && old_lhs <= new_lhs) { + else if (!old_sat && !new_sat && old_lhs < new_lhs) { cuopt_assert(old_viol && new_viol, ""); base_feas -= (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); } @@ -196,7 +196,6 @@ HDI f_t get_breakthrough_move(typename fj_t::climber_data_t::view_t fj auto bounds = fj.pb.variable_bounds[var_idx]; f_t v_lb = get_lower(bounds); f_t v_ub = get_upper(bounds); - cuopt_assert(isfinite(v_lb) || isfinite(v_ub), "unexpected free variable"); cuopt_assert(v_lb <= v_ub, "invalid bounds"); cuopt_assert(fj.pb.check_variable_within_bounds(var_idx, fj.incumbent_assignment[var_idx]), "invalid incumbent assignment"); @@ -220,10 +219,12 @@ HDI f_t get_breakthrough_move(typename fj_t::climber_data_t::view_t fj new_val = old_val + delta_ij; } - // fallback + // A positive coefficient gives a negative delta, so only the lower bound can be the one broken, + // and a broken bound is finite. Free and half-free variables therefore land here finite too. if (!fj.pb.check_variable_within_bounds(var_idx, new_val)) { new_val = obj_coeff > 0 ? v_lb : v_ub; } + cuopt_assert(isfinite(new_val), "breakthrough move left the representable range"); return new_val; } diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu index 57a6a89479..c375b16441 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cu @@ -9,12 +9,19 @@ #include #include +#include +#include +#include #include "feasibility_jump.cuh" #include "feasibility_jump_impl_common.cuh" #include "fj_cpu.cuh" +#include "fj_cpu_binary.cuh" #include "fj_cpu_worker.cuh" +#include + +#include #include #include @@ -28,9 +35,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -61,6 +70,16 @@ void finalize_fj_cpu_host_initialization( i_t nnz, const typename mip_solver_settings_t::tolerances_t& tolerances); +template +static void finalize_fj_cpu_host_initialization_from_template( + fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances); + template thrust::tuple get_mtm_for_bound(const typename fj_t::climber_data_t::view_t& fj, i_t var_idx, @@ -85,22 +104,19 @@ thrust::tuple get_mtm_for_bound(const typename fj_t::climber } template -thrust::tuple get_mtm_for_constraint( - const typename fj_t::climber_data_t::view_t& fj, - i_t var_idx, - i_t cstr_idx, - f_t cstr_coeff, - f_t c_lb, - f_t c_ub, - const ArrayType& assignment, - const ArrayType& lhs_vector) +thrust::tuple get_mtm_for_constraint(i_t var_idx, + i_t cstr_idx, + f_t cstr_coeff, + f_t c_lb, + f_t c_ub, + const ArrayType& assignment, + const ArrayType& lhs_vector, + f_t cstr_tolerance) { f_t sign = -1; f_t delta_ij = 0; f_t slack = 0; - f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); - f_t old_val = assignment[var_idx]; // process each bound as two separate constraints @@ -131,8 +147,7 @@ thrust::tuple get_mtm_for_constraint( } template -std::pair feas_score_constraint(const typename fj_t::climber_data_t::view_t& fj, - i_t var_idx, +std::pair feas_score_constraint(fj_cpu_climber_t& fj_cpu, f_t delta, i_t cstr_idx, f_t cstr_coeff, @@ -140,33 +155,39 @@ std::pair feas_score_constraint(const typename fj_t::climber f_t c_ub, f_t current_lhs, f_t left_weight, - f_t right_weight) + f_t right_weight, + f_t cstr_tolerance) { + const auto& fj = fj_cpu.view; cuopt_assert(isfinite(delta), "invalid delta"); - cuopt_assert(cstr_coeff != 0 && isfinite(cstr_coeff), "invalid coefficient"); + // A model may store explicit zeros, and a zero coefficient contributes nothing to the row. + cuopt_assert(isfinite(cstr_coeff), "invalid coefficient"); f_t base_feas = 0; f_t bonus_robust = 0; f_t bounds[2] = {c_lb, c_ub}; cuopt_assert(isfinite(c_lb) || isfinite(c_ub), "no range"); + + // Independent of bound_idx. + const f_t moved_lhs = current_lhs + cstr_coeff * delta; + const bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; + const bool new_viol = fj.excess_score(cstr_idx, moved_lhs, c_lb, c_ub) < -cstr_tolerance; + for (i_t bound_idx = 0; bound_idx < 2; ++bound_idx) { if (!isfinite(bounds[bound_idx])) continue; - // factor to correct the lhs/rhs to turn a lb <= lhs <= ub constraint into - // two virtual leq constraints "lhs <= ub" and "-lhs <= -lb" in order to match - // the convention of the paper - - // TODO: broadcast left/right weights to a csr_offset-indexed table? local minimums - // usually occur on a rarer basis (around 50 iteratiosn to 1 local minimum) - // likely unreasonable and overkill however + // factor to correct the lhs/rhs to turn a lb <= lhs <= ub constraint into two virtual leq + // constraints "lhs <= ub" and "-lhs <= -lb", to match the convention of the paper f_t cstr_weight = bound_idx == 0 ? left_weight : right_weight; f_t sign = bound_idx == 0 ? -1 : 1; f_t rhs = bounds[bound_idx] * sign; f_t old_lhs = current_lhs * sign; - f_t new_lhs = (current_lhs + cstr_coeff * delta) * sign; - f_t old_slack = rhs - old_lhs; - f_t new_slack = rhs - new_lhs; + f_t new_lhs = moved_lhs * sign; + [[maybe_unused]] + f_t old_slack = rhs - old_lhs; + [[maybe_unused]] + f_t new_slack = rhs - new_lhs; cuopt_assert(isfinite(cstr_weight), "invalid weight"); cuopt_assert(cstr_weight >= 0, "invalid weight"); @@ -174,12 +195,6 @@ std::pair feas_score_constraint(const typename fj_t::climber cuopt_assert(isfinite(new_lhs), ""); cuopt_assert(isfinite(old_slack) && isfinite(new_slack), ""); - f_t cstr_tolerance = fj.get_corrected_tolerance(cstr_idx, c_lb, c_ub); - - bool old_viol = fj.excess_score(cstr_idx, current_lhs, c_lb, c_ub) < -cstr_tolerance; - bool new_viol = - fj.excess_score(cstr_idx, current_lhs + cstr_coeff * delta, c_lb, c_ub) < -cstr_tolerance; - bool old_sat = old_lhs < rhs + cstr_tolerance; bool new_sat = new_lhs < rhs + cstr_tolerance; @@ -202,12 +217,12 @@ std::pair feas_score_constraint(const typename fj_t::climber // simple improvement else if (!old_sat && !new_sat && old_lhs > new_lhs) { cuopt_assert(old_viol && new_viol, ""); - base_feas += (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); + base_feas += (i_t)(cstr_weight * fj_cpu.settings.parameters.excess_improvement_weight); } // simple worsening - else if (!old_sat && !new_sat && old_lhs <= new_lhs) { + else if (!old_sat && !new_sat && old_lhs < new_lhs) { cuopt_assert(old_viol && new_viol, ""); - base_feas -= (i_t)(cstr_weight * fj.settings->parameters.excess_improvement_weight); + base_feas -= (i_t)(cstr_weight * fj_cpu.settings.parameters.excess_improvement_weight); } // robustness score bonus if this would leave some strick slack @@ -275,43 +290,43 @@ static void print_timing_stats(fj_cpu_climber_t& fj_cpu) auto [apply_avg, apply_total] = compute_avg_and_total(fj_cpu.apply_move_times); auto [weights_avg, weights_total] = compute_avg_and_total(fj_cpu.update_weights_times); auto [compute_score_avg, compute_score_total] = compute_avg_and_total(fj_cpu.compute_score_times); - CUOPT_LOG_TRACE("=== Timing Statistics (Iteration %d) ===", fj_cpu.iterations); - CUOPT_LOG_TRACE("find_lift_move: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("=== Timing Statistics (Iteration %d) ===", fj_cpu.iterations); + CUOPT_LOG_DEBUG("find_lift_move: avg=%.6f ms, total=%.6f ms, calls=%zu", lift_avg * 1000.0, lift_total * 1000.0, fj_cpu.find_lift_move_times.size()); - CUOPT_LOG_TRACE("find_mtm_move_viol: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("find_mtm_move_viol: avg=%.6f ms, total=%.6f ms, calls=%zu", viol_avg * 1000.0, viol_total * 1000.0, fj_cpu.find_mtm_move_viol_times.size()); - CUOPT_LOG_TRACE("find_mtm_move_sat: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("find_mtm_move_sat: avg=%.6f ms, total=%.6f ms, calls=%zu", sat_avg * 1000.0, sat_total * 1000.0, fj_cpu.find_mtm_move_sat_times.size()); - CUOPT_LOG_TRACE("apply_move: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("apply_move: avg=%.6f ms, total=%.6f ms, calls=%zu", apply_avg * 1000.0, apply_total * 1000.0, fj_cpu.apply_move_times.size()); - CUOPT_LOG_TRACE("update_weights: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("update_weights: avg=%.6f ms, total=%.6f ms, calls=%zu", weights_avg * 1000.0, weights_total * 1000.0, fj_cpu.update_weights_times.size()); - CUOPT_LOG_TRACE("compute_score: avg=%.6f ms, total=%.6f ms, calls=%zu", + CUOPT_LOG_DEBUG("compute_score: avg=%.6f ms, total=%.6f ms, calls=%zu", compute_score_avg * 1000.0, compute_score_total * 1000.0, fj_cpu.compute_score_times.size()); - CUOPT_LOG_TRACE("cache hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("cache hit percentage: %.2f%%", (double)fj_cpu.hit_count / (fj_cpu.hit_count + fj_cpu.miss_count) * 100.0); - CUOPT_LOG_TRACE("bin candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("bin candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[0] / (fj_cpu.candidate_move_hits[0] + fj_cpu.candidate_move_misses[0]) * 100.0); - CUOPT_LOG_TRACE("int candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("int candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[1] / (fj_cpu.candidate_move_hits[1] + fj_cpu.candidate_move_misses[1]) * 100.0); - CUOPT_LOG_TRACE("cont candidate move hit percentage: %.2f%%", + CUOPT_LOG_DEBUG("cont candidate move hit percentage: %.2f%%", (double)fj_cpu.candidate_move_hits[2] / (fj_cpu.candidate_move_hits[2] + fj_cpu.candidate_move_misses[2]) * 100.0); - CUOPT_LOG_TRACE("========================================"); + CUOPT_LOG_DEBUG("========================================"); } template @@ -373,6 +388,11 @@ static void precompute_problem_features(fj_cpu_climber_t& fj_cpu) fj_cpu.problem_density = (double)total_nnz / ((double)n_vars * n_cstrs); } +// Greedy first-fit colouring of the variable co-occurrence graph, where each row is a clique. The +// adjacency is walked per variable and never stored: the clique expansion is far larger than nnz. +template +static void compute_variable_coloring(fj_cpu_climber_t& fj_cpu); + template static void log_regression_features(fj_cpu_climber_t& fj_cpu, double time_window_ms, @@ -396,9 +416,9 @@ static void log_regression_features(fj_cpu_climber_t& fj_cpu, double eval_intensity = (double)fj_cpu.nnz_processed_window / 1000.0; // Cache and locality metrics - i_t cache_hits_window = fj_cpu.hit_count - fj_cpu.hit_count_window_start; - i_t cache_misses_window = fj_cpu.miss_count - fj_cpu.miss_count_window_start; - i_t total_cache_accesses = cache_hits_window + cache_misses_window; + int64_t cache_hits_window = fj_cpu.hit_count - fj_cpu.hit_count_window_start; + int64_t cache_misses_window = fj_cpu.miss_count - fj_cpu.miss_count_window_start; + int64_t total_cache_accesses = cache_hits_window + cache_misses_window; double cache_hit_rate = total_cache_accesses > 0 ? (double)cache_hits_window / total_cache_accesses : 0.0; @@ -536,6 +556,207 @@ static inline std::pair range_for_constraint(fj_cpu_climber_t +static void compute_variable_coloring(fj_cpu_climber_t& fj_cpu) +{ + const i_t n_vars = fj_cpu.view.pb.n_variables; + const i_t n_cstrs = fj_cpu.view.pb.n_constraints; + + i_t max_row_length = 0; + double clique_edges = 0; + for (i_t row = 0; row < n_cstrs; ++row) { + const i_t length = fj_cpu.h_offsets[row + 1] - fj_cpu.h_offsets[row]; + max_row_length = std::max(max_row_length, length); + if (length > 1) clique_edges += (double)length * (length - 1) / 2.0; + } + if (n_vars <= 0 || max_row_length <= 0) return; + + const double class_size = (double)n_vars / max_row_length; + const double edges_per_nnz = clique_edges / std::max(1, (double)fj_cpu.view.pb.nnz); + if (class_size < fj_batch_min_class_size || edges_per_nnz > fj_batch_max_edges_per_nnz) { + CUOPT_LOG_DEBUG("CPUFJ move batching declined: class size %.2f, clique edges/nnz %.2f", + class_size, + edges_per_nnz); + return; + } + + const auto started = std::chrono::steady_clock::now(); + fj_cpu.h_var_color.assign(n_vars, -1); + fj_cpu.n_colors = 0; + std::vector neighbor_stamp(n_vars, -1); + std::vector color_stamp(n_vars, -1); + + for (i_t var = 0; var < n_vars; ++var) { + const auto [rev_begin, rev_end] = reverse_range_for_var(fj_cpu, var); + for (i_t p = rev_begin; p < rev_end; ++p) { + const auto [begin, end] = + range_for_constraint(fj_cpu, fj_cpu.h_reverse_constraints[p]); + for (i_t k = begin; k < end; ++k) { + const i_t other = fj_cpu.h_variables[k]; + if (other == var || neighbor_stamp[other] == var) continue; + neighbor_stamp[other] = var; + const i_t taken = fj_cpu.h_var_color[other]; + if (taken >= 0) color_stamp[taken] = var; + } + } + + i_t color = 0; + while (color < fj_cpu.n_colors && color_stamp[color] == var) ++color; + if (color == fj_cpu.n_colors) ++fj_cpu.n_colors; + fj_cpu.h_var_color[var] = color; + } + + fj_cpu.h_var_best_score.assign(n_vars, fj_staged_score_t::invalid()); + fj_cpu.h_var_best_delta.assign(n_vars, f_t{0}); + fj_cpu.h_var_best_stamp.assign(n_vars, 0); + fj_cpu.h_var_best_rowsum.assign(n_vars, 0); + fj_cpu.h_var_bucket_stamp.assign(n_vars, 0); + fj_cpu.batch_size_hist.assign(fj_batch_hist_bins, 0); + fj_cpu.h_color_candidates.assign(fj_cpu.n_colors, {}); + fj_cpu.h_color_epoch.assign(fj_cpu.n_colors, 0); + fj_cpu.var_best_epoch = 1; + + CUOPT_LOG_DEBUG("CPUFJ move batching: %d colours over %d variables in %.3f ms", + fj_cpu.n_colors, + n_vars, + std::chrono::duration(std::chrono::steady_clock::now() - + started) + .count()); +} + +// Sum of the versions of the rows a variable appears in. Versions only ever increase, so an +// unchanged sum means no incident row has been touched. +template +static inline int64_t incident_row_version_sum(fj_cpu_climber_t& fj_cpu, i_t var_idx) +{ + const auto [begin, end] = reverse_range_for_var(fj_cpu, var_idx); + int64_t sum = 0; + for (i_t p = begin; p < end; ++p) + sum += fj_cpu.h_cstr_version[fj_cpu.h_reverse_constraints[p]]; + return sum; +} + +// Records a candidate move for its variable. The table keeps a best per variable, independent of +// the argmax the caller is tracking, which is what lets a batch be assembled later. +template +static inline void record_var_best_move(fj_cpu_climber_t& fj_cpu, + i_t var_idx, + fj_staged_score_t score, + f_t delta) +{ + if (!fj_cpu.use_move_batching) return; + if (!(score > fj_staged_score_t::zero())) return; + + const bool current = fj_cpu.h_var_best_stamp[var_idx] == fj_cpu.var_best_epoch; + if (current && !(score > fj_cpu.h_var_best_score[var_idx])) return; + + fj_cpu.h_var_best_score[var_idx] = score; + fj_cpu.h_var_best_delta[var_idx] = delta; + fj_cpu.h_var_best_stamp[var_idx] = fj_cpu.var_best_epoch; + fj_cpu.h_var_best_rowsum[var_idx] = incident_row_version_sum(fj_cpu, var_idx); + + const i_t color = fj_cpu.h_var_color[var_idx]; + cuopt_assert(color >= 0 && color < fj_cpu.n_colors, "variable has no colour"); + if (fj_cpu.h_color_epoch[color] != fj_cpu.var_best_epoch) { + fj_cpu.h_color_candidates[color].clear(); + fj_cpu.h_color_epoch[color] = fj_cpu.var_best_epoch; + } + if (fj_cpu.h_var_bucket_stamp[var_idx] == fj_cpu.var_best_epoch) return; + fj_cpu.h_var_bucket_stamp[var_idx] = fj_cpu.var_best_epoch; + fj_cpu.h_color_candidates[color].push_back(var_idx); +} + +// Retires the whole table in constant time. Called wherever the weights or the assignment move far +// enough that every cached score is suspect. +template +static inline void retire_var_best_moves(fj_cpu_climber_t& fj_cpu) +{ + if (!fj_cpu.use_move_batching) return; + ++fj_cpu.var_best_epoch; +} + +// Companions per batch attempt, as min, median, max and mean. A median landing in the saturating +// last bin reads as that bin's index, and max_batch_size carries the true tail. +template +static void log_batch_distribution(const fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.n_batch_attempts == 0) return; + + int32_t smallest = -1; + int32_t median = -1; + int64_t seen = 0; + for (size_t bin = 0; bin < fj_cpu.batch_size_hist.size(); ++bin) { + if (fj_cpu.batch_size_hist[bin] == 0) continue; + if (smallest < 0) smallest = (int32_t)bin; + seen += fj_cpu.batch_size_hist[bin]; + if (median < 0 && 2 * seen > fj_cpu.n_batch_attempts) median = (int32_t)bin; + } + + CUOPT_LOG_DEBUG( + "%sCPUFJ batch companions: min %d median %d max %lld mean %.3f over %lld attempts, %lld total, " + "%d colours, batching %s", + fj_cpu.log_prefix.c_str(), + smallest, + median, + (long long)fj_cpu.max_batch_size, + (double)fj_cpu.n_batched_moves / (double)fj_cpu.n_batch_attempts, + (long long)fj_cpu.n_batch_attempts, + (long long)fj_cpu.n_batched_moves, + fj_cpu.n_colors, + fj_cpu.use_move_batching ? "on" : "off"); +} + +// Companions for the chosen move: same colour, so they share no row with it or with each other and +// their recorded scores and deltas hold as the batch is applied. Excludes the chosen move itself. +template +static void collect_move_batch(fj_cpu_climber_t& fj_cpu, + fj_move_t chosen, + std::vector& batch) +{ + batch.clear(); + if (!fj_cpu.use_move_batching) return; + + const i_t color = fj_cpu.h_var_color[chosen.var_idx]; + cuopt_assert(color >= 0 && color < fj_cpu.n_colors, "chosen move has no colour"); + if (fj_cpu.h_color_epoch[color] != fj_cpu.var_best_epoch) return; + + for (i_t var_idx : fj_cpu.h_color_candidates[color]) { + if (var_idx == chosen.var_idx) continue; + if (fj_cpu.h_var_best_stamp[var_idx] != fj_cpu.var_best_epoch) continue; + if (!(fj_cpu.h_var_best_score[var_idx] > fj_staged_score_t::zero())) continue; + if (fj_cpu.h_var_best_rowsum[var_idx] != incident_row_version_sum(fj_cpu, var_idx)) + continue; + + batch.push_back({var_idx, fj_cpu.h_var_best_delta[var_idx]}); + // Invalidated so a second pass over the bucket cannot apply the move twice. + fj_cpu.h_var_best_stamp[var_idx] = 0; + } + + ++fj_cpu.n_batch_attempts; + fj_cpu.n_batched_moves += (int64_t)batch.size(); + ++fj_cpu.batch_size_hist[std::min(batch.size(), fj_cpu.batch_size_hist.size() - 1)]; + if ((int64_t)batch.size() > fj_cpu.max_batch_size) + fj_cpu.max_batch_size = (int64_t)batch.size(); + if (fj_cpu.n_batch_attempts == fj_batch_probe_attempts && + (double)fj_cpu.n_batched_moves < fj_batch_min_yield * (double)fj_batch_probe_attempts) { + fj_cpu.use_move_batching = false; + CUOPT_LOG_DEBUG("%sCPUFJ move batching off: %lld companions over %lld attempts", + fj_cpu.log_prefix.c_str(), + (long long)fj_cpu.n_batched_moves, + (long long)fj_cpu.n_batch_attempts); + } +} + template static inline bool check_variable_within_bounds(fj_cpu_climber_t& fj_cpu, i_t var_idx, @@ -547,6 +768,69 @@ static inline bool check_variable_within_bounds(fj_cpu_climber_t& fj_c return within_bounds; } +// Names the first variable whose assignment sits outside its own bounds, so the writer that left it +// there is identified by the call site. Scans, and is only reached through cuopt_func_call. +template +static void audit_assignment_bounds(fj_cpu_climber_t& fj_cpu, const char* site) +{ + for (i_t var = 0; var < fj_cpu.view.pb.n_variables; ++var) { + const f_t val = fj_cpu.h_assignment[var]; + auto bounds = fj_cpu.h_var_bounds[var].get(); + const bool inbox = fj_cpu.view.pb.check_variable_within_bounds(var, val); + const bool integral = + var_t::INTEGER != fj_cpu.h_var_types[var] || fj_cpu.view.pb.is_integer(val); + if (inbox && integral) continue; + + // stderr and flushed, so the abort below cannot swallow it. + std::fprintf(stderr, + "%sCPUFJ %s left var %d at %.17g outside [%.17g, %.17g], integer %d\n", + fj_cpu.log_prefix.c_str(), + site, + (int)var, + (double)val, + (double)get_lower(bounds), + (double)get_upper(bounds), + (int)(var_t::INTEGER == fj_cpu.h_var_types[var])); + std::fflush(stderr); + cuopt_assert(false, "assignment left the variable bounds"); + return; + } +} + +// Reports the first objective variable get_breakthrough_move would reject, reading the value both +// from the climber's vector and through the view span so a bad value is told from a stale span. +template +static void audit_breakthrough_inputs(fj_cpu_climber_t& fj_cpu) +{ + for (auto var_idx : fj_cpu.h_objective_vars) { + const f_t viewed = fj_cpu.view.incumbent_assignment[var_idx]; + if (fj_cpu.view.pb.check_variable_within_bounds(var_idx, viewed)) continue; + + const f_t direct = fj_cpu.h_assignment[var_idx]; + auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + auto viewed_bnd = fj_cpu.view.pb.variable_bounds[var_idx]; + // stderr and flushed, so the abort below cannot swallow it. + std::fprintf(stderr, + "%sCPUFJ breakthrough input var %d: direct %.17g viewed %.17g nan %d, bounds " + "direct [%.17g, %.17g] viewed [%.17g, %.17g], obj %.17g, degree %d, integer %d\n", + fj_cpu.log_prefix.c_str(), + (int)var_idx, + (double)direct, + (double)viewed, + (int)(viewed != viewed), + (double)get_lower(bounds), + (double)get_upper(bounds), + (double)get_lower(viewed_bnd), + (double)get_upper(viewed_bnd), + (double)fj_cpu.h_obj_coeffs[var_idx], + (int)(fj_cpu.h_reverse_offsets[var_idx + 1] - fj_cpu.h_reverse_offsets[var_idx]), + (int)(var_t::INTEGER == fj_cpu.h_var_types[var_idx])); + std::fflush(stderr); + cuopt_assert(false, "breakthrough move input out of bounds"); + return; + } +} + template static inline bool is_integer_var(fj_cpu_climber_t& fj_cpu, i_t var_idx) { @@ -603,35 +887,60 @@ static inline std::pair compute_score(fj_cpu_climber_t(fj_cpu, var_idx); fj_cpu.nnz_processed_window += (offset_end - offset_begin); + const size_t nnz_read = (size_t)(offset_end - offset_begin); + ++fj_cpu.n_compute_score_calls; + fj_cpu.compute_score_nnz += (int64_t)nnz_read; + fj_cpu.h_reverse_constraints.byte_loads += nnz_read * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_read * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_left_weights.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_right_weights.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.h_cstr_tolerance.byte_loads += nnz_read * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const f_t* const weight_l = fj_cpu.view.cstr_left_weights.data(); + const f_t* const weight_r = fj_cpu.view.cstr_right_weights.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + for (i_t i = offset_begin; i < offset_end; i++) { - auto cstr_idx = fj_cpu.h_reverse_constraints[i]; - fj_cpu.unique_cstrs_accessed_window.insert(cstr_idx); - auto cstr_coeff = fj_cpu.h_reverse_coefficients[i]; - auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); + const i_t cstr_idx = rev_cstr[i]; + const f_t cstr_coeff = rev_coeff[i]; + const auto [c_lb, c_ub] = cstr_bounds[i]; + // An explicit zero moves no row, so the move cannot change this row's score. + if (cstr_coeff == f_t{0}) continue; cuopt_assert(c_lb <= c_ub, "invalid bounds"); - auto [cstr_base_feas, cstr_bonus_robust] = - feas_score_constraint(fj_cpu.view, - var_idx, - delta, - cstr_idx, - cstr_coeff, - c_lb, - c_ub, - fj_cpu.h_lhs[cstr_idx], - fj_cpu.h_cstr_left_weights[cstr_idx], - fj_cpu.h_cstr_right_weights[cstr_idx]); + auto [cstr_base_feas, cstr_bonus_robust] = feas_score_constraint(fj_cpu, + delta, + cstr_idx, + cstr_coeff, + c_lb, + c_ub, + row_lhs[cstr_idx], + weight_l[cstr_idx], + weight_r[cstr_idx], + row_tol[cstr_idx]); base_feas_sum += cstr_base_feas; bonus_robust_sum += cstr_bonus_robust; } f_t base_obj = 0; - if (obj_diff < 0) // improving move wrt objective - base_obj = fj_cpu.h_objective_weight; - else if (obj_diff > 0) - base_obj = -fj_cpu.h_objective_weight; + if (fj_cpu.h_objective_weight > 0 && obj_diff != 0) { + // Scaling base is only meaningful where there is feasibility impact to trade against. + f_t weighted = fj_cpu.h_objective_weight; + if (base_feas_sum != 0) { + cuopt_assert(fj_cpu.obj_magnitude > 0, "objective magnitude unit must be positive"); + weighted *= min((f_t)fj_obj_mult_max, + max((f_t)fj_obj_mult_min, fabs(obj_diff) / fj_cpu.obj_magnitude)); + } + base_obj = obj_diff < 0 ? weighted : -weighted; + } f_t bonus_breakthrough = 0; @@ -649,13 +958,277 @@ static inline std::pair compute_score(fj_cpu_climber_t::max()}; + + bool operator>(const two_opt_move_t& other) const + { + if (score != other.score) return score > other.score; + if (age != other.age) return age < other.age; + if (first.var_idx != other.first.var_idx) return first.var_idx < other.first.var_idx; + return second.var_idx < other.second.var_idx; + } +}; + +// returns the combined score of a joint 2opt move +template +static fj_staged_score_t two_opt_compute_pair_score( + fj_cpu_climber_t& fj_cpu, i_t first, f_t first_delta, i_t second, f_t second_delta) +{ + auto& row_deltas = fj_cpu.two_opt_row_deltas; + row_deltas.clear(); + const fj_move_t endpoints[2] = {{first, first_delta}, {second, second_delta}}; + for (const auto& [var_idx, delta] : endpoints) { + const auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + fj_cpu.nnz_processed_window += offset_end - offset_begin; + for (i_t i = offset_begin; i < offset_end; ++i) { + const i_t cstr_idx = fj_cpu.h_reverse_constraints[i]; + const f_t coeff = fj_cpu.h_reverse_coefficients[i]; + row_deltas.emplace_back(cstr_idx, coeff * delta); + } + } + // Brings the entries of a shared row next to each other + std::sort(row_deltas.begin(), row_deltas.end()); + + f_t base_feas_sum = 0; + f_t bonus_robust_sum = 0; + for (size_t pos = 0; pos < row_deltas.size();) { + const i_t cstr_idx = row_deltas[pos].first; + f_t lhs_delta = 0; + do { + lhs_delta += row_deltas[pos++].second; + } while (pos < row_deltas.size() && row_deltas[pos].first == cstr_idx); + + // The coefficients are already folded into lhs_delta, hence the unit coefficient + auto [cstr_base_feas, cstr_bonus_robust] = + feas_score_constraint(fj_cpu, + lhs_delta, + cstr_idx, + 1, + fj_cpu.h_cstr_lb[cstr_idx], + fj_cpu.h_cstr_ub[cstr_idx], + fj_cpu.h_lhs[cstr_idx], + fj_cpu.h_cstr_left_weights[cstr_idx], + fj_cpu.h_cstr_right_weights[cstr_idx], + fj_cpu.h_cstr_tolerance[cstr_idx]); + base_feas_sum += cstr_base_feas; + bonus_robust_sum += cstr_bonus_robust; + } + + const f_t obj_diff = + fj_cpu.h_obj_coeffs[first] * first_delta + fj_cpu.h_obj_coeffs[second] * second_delta; + f_t base_obj = 0; + if (obj_diff < 0) + base_obj = fj_cpu.h_objective_weight; + else if (obj_diff > 0) + base_obj = -fj_cpu.h_objective_weight; + + f_t bonus_breakthrough = 0; + bool old_obj_better = fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective; + bool new_obj_better = fj_cpu.h_incumbent_objective + obj_diff < fj_cpu.h_best_objective; + if (!old_obj_better && new_obj_better) + bonus_breakthrough += fj_cpu.h_objective_weight; + else if (old_obj_better && !new_obj_better) + bonus_breakthrough -= fj_cpu.h_objective_weight; + + fj_staged_score_t score; + score.base = round(base_obj + base_feas_sum); + score.bonus = round(bonus_breakthrough + bonus_robust_sum); + return score; +} + +template +static void two_opt_add_partner(fj_cpu_climber_t& fj_cpu, + i_t first, + i_t var_idx, + f_t target) +{ + if (var_idx == first) return; + const f_t val = fj_cpu.h_assignment[var_idx].get(); + // A partner between two integers has no opposite value to swap to + if (!fj_cpu.view.pb.is_integer(val)) return; + const f_t delta = target - val; + // Already at the value we would move it to, so there is no compound move to make + if (fabs(delta) < 0.5) return; + if (!check_variable_within_bounds(fj_cpu, var_idx, target)) return; + if (tabu_check(fj_cpu, var_idx, delta, true)) return; + fj_cpu.two_opt_partners.emplace_back(var_idx, delta); +} + +/** + * @brief Fill fj_cpu.two_opt_partners with candidates to flip together with `first`. + * + * Preferred source is the probing cache: it recorded, for each probed variable and value, the + * bounds propagation implies on every other variable. An implied bound pinning a binary to a value + * names both the partner and the value it has to take once `first` moves, so a pair moving in the + * same direction is reached as naturally as a swap. The + * variables sharing a row with it are used as fallback. + */ +template +static void two_opt_collect_partners(fj_cpu_climber_t& fj_cpu, + i_t first, + f_t first_delta, + size_t max_partners) +{ + auto& partners = fj_cpu.two_opt_partners; + const i_t n_variables = fj_cpu.view.pb.n_variables; + partners.clear(); + cuopt_assert(fj_cpu.h_is_binary_variable[first], "2-opt is only defined for binaries"); + cuopt_assert( + fj_cpu.probing_cache == nullptr || fj_cpu.h_original_ids.size() == (size_t)n_variables, + "original id map does not cover every variable"); + cuopt_assert(fj_cpu.probing_cache == nullptr || + fj_cpu.h_reverse_original_ids.size() >= fj_cpu.h_original_ids.size(), + "reverse original id map smaller than the problem"); + + if (fj_cpu.probing_cache != nullptr) { + const auto& cache = fj_cpu.probing_cache->probing_cache; + const auto cached_probe = cache.find(fj_cpu.h_original_ids[first]); + if (cached_probe != cache.end()) { + const f_t new_val = fj_cpu.h_assignment[first].get() + first_delta; + i_t hit_interval = -1; + i_t unused_hit = -1; + for (i_t interval = 0; interval < 2; ++interval) { + const auto& entry = cached_probe->second[interval]; + if (entry.var_to_cached_bound_map.empty()) { continue; } + entry.val_interval.fill_cache_hits(interval, new_val, new_val, hit_interval, unused_hit); + } + if (hit_interval != -1) { + const auto& implications = cached_probe->second[hit_interval].var_to_cached_bound_map; + for (const auto& [probed_id, implied] : implications) { + if (partners.size() >= max_partners) break; + const i_t var_idx = fj_cpu.h_reverse_original_ids[probed_id]; + // -1 means presolve removed the variable after the probe recorded it + if (var_idx < 0) { continue; } + cuopt_assert(var_idx < n_variables, "implied variable out of range"); + if (!fj_cpu.h_is_binary_variable[var_idx]) { continue; } + if (!fj_cpu.view.pb.integer_equal(implied.lb, implied.ub)) { continue; } + two_opt_add_partner(fj_cpu, first, var_idx, round(implied.lb)); + } + } + } + } + + const auto& related = fj_cpu.h_related_variables; + const auto& related_offsets = fj_cpu.h_related_variables_offsets; + if (related_offsets.size() != (size_t)n_variables + 1) return; + const f_t swap_target = fj_cpu.h_assignment[first].get(); + const i_t related_begin = related_offsets[first]; + const i_t related_end = related_offsets[first + 1]; + for (i_t i = related_begin; i < related_end && partners.size() < max_partners; ++i) { + const i_t var_idx = related[i]; + if (fj_cpu.h_is_binary_variable[var_idx]) { + two_opt_add_partner(fj_cpu, first, var_idx, swap_target); + } + } +} + +// Look for binary 2opt moves at a local minimum. by definition no 1opt move can improve, but +// combined moves may especially in the case of set partitioning constraints / cliques. Use +// information from the probing cache to find potential good 2opt moves. +template +static two_opt_move_t find_two_opt_move(fj_cpu_climber_t& fj_cpu) +{ + CPUFJ_NVTX_RANGE("CPUFJ::find_two_opt_move"); + constexpr size_t max_obj_starts = 64; + constexpr size_t max_partners_per_var = 16; + + const auto& params = fj_cpu.settings.parameters; + const size_t max_target_rows = params.two_opt_max_rows; + const size_t max_first_vars = params.two_opt_max_row_vars; + const size_t max_pairs = params.two_opt_max_pairs; + + two_opt_move_t best; + + const bool partner_source_exists = + (fj_cpu.probing_cache != nullptr && !fj_cpu.probing_cache->probing_cache.empty()) || + (int64_t)fj_cpu.h_related_variables_offsets.size() == fj_cpu.view.pb.n_variables + 1; + + if (fj_cpu.n_binary_vars == 0 || !partner_source_exists) return best; + + auto& first_vars = fj_cpu.two_opt_first_vars; + first_vars.clear(); + + // target binvars in violated constraints for flips + if (!fj_cpu.violated_constraints.empty()) { + cuopt_assert(fj_cpu.h_binrow_offsets.size() == fj_cpu.view.pb.n_constraints + 1, + "binary row table missing"); + auto& target_cstrs = fj_cpu.two_opt_target_cstrs; + target_cstrs.clear(); + std::sample(fj_cpu.violated_constraints.begin(), + fj_cpu.violated_constraints.end(), + std::back_inserter(target_cstrs), + max_target_rows, + fj_cpu.rng); + for (i_t cstr_idx : target_cstrs) { + const i_t bin_begin = fj_cpu.h_binrow_offsets[cstr_idx]; + const i_t bin_end = fj_cpu.h_binrow_offsets[cstr_idx + 1]; + for (i_t i = bin_begin; i < bin_end && first_vars.size() < max_first_vars; ++i) { + first_vars.push_back(fj_cpu.h_binrow_vars[i].get()); + } + } + } else { + // target objective-bearing binary vars in satisfied constraints + std::sample(fj_cpu.h_objective_vars.underlying().begin(), + fj_cpu.h_objective_vars.underlying().end(), + std::back_inserter(first_vars), + max_obj_starts, + fj_cpu.rng); + first_vars.erase(std::remove_if(first_vars.begin(), + first_vars.end(), + [&](i_t var_idx) { + if (!fj_cpu.h_is_binary_variable[var_idx]) return true; + const f_t delta = + round(1 - 2 * fj_cpu.h_assignment[var_idx].get()); + return fj_cpu.h_obj_coeffs[var_idx] * delta >= 0; + }), + first_vars.end()); + } + std::shuffle(first_vars.begin(), first_vars.end(), fj_cpu.rng); + + const i_t nnz_at_entry = fj_cpu.nnz_processed_window; + size_t pairs_scored = 0; + // find a (first, second) pair for the 2opt + for (i_t first : first_vars) { + if (pairs_scored >= max_pairs) break; + if (fj_cpu.nnz_processed_window - nnz_at_entry > fj_cpu.nnz_samples) break; + const f_t first_val = fj_cpu.h_assignment[first].get(); + if (!fj_cpu.view.pb.is_integer(first_val)) continue; + const f_t first_delta = round(1 - 2 * first_val); + if (tabu_check(fj_cpu, first, first_delta, true)) continue; + if (!check_variable_within_bounds(fj_cpu, first, first_val + first_delta)) continue; + const i_t first_touch = std::max(fj_cpu.h_tabu_lastinc[first], fj_cpu.h_tabu_lastdec[first]); + + // look for potential other binary vars to flip alongside the first var + two_opt_collect_partners(fj_cpu, first, first_delta, max_partners_per_var); + for (const auto& [second, second_delta] : fj_cpu.two_opt_partners) { + const i_t second_touch = + std::max(fj_cpu.h_tabu_lastinc[second], fj_cpu.h_tabu_lastdec[second]); + two_opt_move_t cand; + cand.first = {first, first_delta}; + cand.second = {second, second_delta}; + cand.score = two_opt_compute_pair_score(fj_cpu, first, first_delta, second, second_delta); + cand.age = std::max(first_touch, second_touch); + if (cand > best) { best = cand; } + ++pairs_scored; + + if (pairs_scored >= max_pairs) return best; + if (fj_cpu.nnz_processed_window - nnz_at_entry > fj_cpu.nnz_samples) return best; + } + } + return best; +} + template static void smooth_weights(fj_cpu_climber_t& fj_cpu) { CPUFJ_NVTX_RANGE("CPUFJ::smooth_weights"); for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; cstr_idx++) { // consider only satisfied constraints - if (fj_cpu.violated_constraints.count(cstr_idx)) continue; + if (fj_cpu.violated_constraints.contains(cstr_idx)) continue; f_t weight_l = max((f_t)0, fj_cpu.h_cstr_left_weights[cstr_idx] - 1); f_t weight_r = max((f_t)0, fj_cpu.h_cstr_right_weights[cstr_idx] - 1); @@ -665,8 +1238,74 @@ static void smooth_weights(fj_cpu_climber_t& fj_cpu) } if (fj_cpu.h_objective_weight > 0 && fj_cpu.h_incumbent_objective >= fj_cpu.h_best_objective) { - fj_cpu.h_objective_weight = max((f_t)0, fj_cpu.h_objective_weight - 1); + fj_cpu.h_objective_weight = + max(fj_cpu.seed_objective_weight, fj_cpu.h_objective_weight - 1); + } +} + +// Escalation threshold and step for the violated-row bump, in local minima without a severity gain. +constexpr int32_t fj_weight_escalate_after = 2000; +constexpr int32_t fj_weight_escalate_max = 100; + +// Satisfied neighbours sampled per violated row for the donation, and the floor a donor keeps. +constexpr int32_t fj_weight_donor_samples = 4; +constexpr double fj_weight_donation_floor = 1.0; + +// DDFW donation: reach through a variable of this violated row to a satisfied neighbour and take +// the bump back off its heavier side, so total weight stays roughly conserved. +template +static void donate_row_weight(fj_cpu_climber_t& fj_cpu, + i_t cstr_idx, + f_t delta, + raft::random::PCGenerator& rng) +{ + const auto [row_begin, row_end] = range_for_constraint(fj_cpu, cstr_idx); + const uint32_t row_width = (uint32_t)(row_end - row_begin); + // What a donor has to carry to still hold the floor once the delta comes off it. + const f_t donor_minimum = (f_t)fj_weight_donation_floor + delta; + i_t donor = -1; + bool donor_left = true; + f_t donor_weight = 0; + + for (i_t sample = 0; row_width > 0 && sample < fj_weight_donor_samples; ++sample) { + const i_t var_idx = fj_cpu.h_variables[row_begin + (i_t)(rng.next_u32() % row_width)]; + const auto [col_begin, col_end] = reverse_range_for_var(fj_cpu, var_idx); + if (col_end <= col_begin) continue; + const i_t candidate = fj_cpu.h_reverse_constraints[ + col_begin + (i_t)(rng.next_u32() % (uint32_t)(col_end - col_begin))]; + if (candidate == cstr_idx || !fj_cpu.satisfied_constraints.contains(candidate)) continue; + + const f_t left = fj_cpu.h_cstr_left_weights[candidate]; + const f_t right = fj_cpu.h_cstr_right_weights[candidate]; + const bool take_left = left >= right; + const f_t weight = take_left ? left : right; + if (weight < donor_minimum) continue; + if (donor >= 0 && weight <= donor_weight) continue; + + donor = candidate; + donor_left = take_left; + donor_weight = weight; } + if (donor < 0) return; + + const f_t donated = donor_weight - delta; + cuopt_assert(donated >= (f_t)fj_weight_donation_floor, "donation broke the weight floor"); + if (donor_left) { + fj_cpu.h_cstr_left_weights[donor] = donated; + } else { + fj_cpu.h_cstr_right_weights[donor] = donated; + } + ++fj_cpu.n_version_bumps_weights; + fj_cpu.h_cstr_version[donor]++; +} + +template +static i_t weight_escalation_delta(const fj_cpu_climber_t& fj_cpu) +{ + const i_t stall = fj_cpu.iters_since_infeasible_improve; + if (stall <= fj_weight_escalate_after) return 1; + const i_t steps = (stall - fj_weight_escalate_after) / fj_weight_escalate_after + 1; + return steps < fj_weight_escalate_max ? steps : fj_weight_escalate_max; } template @@ -678,11 +1317,15 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); bool smoothing = rng.next_float() <= fj_cpu.settings.parameters.weight_smoothing_probability; + retire_var_best_moves(fj_cpu); + if (smoothing) { smooth_weights(fj_cpu); return; } + const i_t escalated_delta = weight_escalation_delta(fj_cpu); + for (auto cstr_idx : fj_cpu.violated_constraints) { f_t curr_incumbent_lhs = fj_cpu.h_lhs[cstr_idx]; f_t curr_lower_excess = @@ -700,7 +1343,7 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) cuopt_assert(curr_excess_score < 0, "constraint not violated"); - i_t int_delta = 1.0; + i_t int_delta = escalated_delta; f_t delta = int_delta; f_t new_weight = old_weight + delta; @@ -714,17 +1357,23 @@ static void update_weights(fj_cpu_climber_t& fj_cpu) fj_cpu.max_weight = max(fj_cpu.max_weight, new_weight); } + // Only before this lane's first crossing: past that the search oscillates in and out of + // feasibility, and draining satisfied rows costs the objective phase. + if (fj_cpu.use_weight_donation && !fj_cpu.feasible_found) + donate_row_weight(fj_cpu, cstr_idx, delta, rng); + // Invalidate related cached move scores - auto [relvar_offset_begin, relvar_offset_end] = - range_for_constraint(fj_cpu, cstr_idx); - for (auto i = relvar_offset_begin; i < relvar_offset_end; i++) { - fj_cpu.cached_mtm_moves[i].first = 0; - } + ++fj_cpu.n_version_bumps_weights; + fj_cpu.h_cstr_version[cstr_idx]++; } if (fj_cpu.violated_constraints.empty()) { fj_cpu.h_objective_weight += 1; } } +// Bump and ceiling applied to the objective weight when a new incumbent lands. +constexpr double fj_obj_weight_incumbent_bump = 4.0; +constexpr double fj_obj_weight_incumbent_cap = 64.0; + template static void apply_move(fj_cpu_climber_t& fj_cpu, i_t var_idx, @@ -759,79 +1408,127 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, fj_cpu.n_variable_updates_window++; fj_cpu.unique_vars_accessed_window.insert(var_idx); - i_t previous_viol = fj_cpu.violated_constraints.size(); + const size_t nnz_touched = (size_t)(offset_end - offset_begin); + ++fj_cpu.n_moves_applied; + fj_cpu.apply_move_nnz += (int64_t)nnz_touched; + fj_cpu.n_version_bumps_apply += (int64_t)nnz_touched; + fj_cpu.h_reverse_constraints.byte_loads += nnz_touched * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_touched * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs.byte_stores += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_loads += nnz_touched * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_stores += nnz_touched * sizeof(f_t); + fj_cpu.h_cstr_tolerance.byte_loads += nnz_touched * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); + f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); for (auto i = offset_begin; i < offset_end; i++) { cuopt_assert(i < (i_t)fj_cpu.h_reverse_constraints.size(), ""); - auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[i].get(); + const auto [c_lb, c_ub] = cstr_bounds[i]; - auto cstr_idx = fj_cpu.h_reverse_constraints[i]; - fj_cpu.unique_cstrs_accessed_window.insert(cstr_idx); - auto cstr_coeff = fj_cpu.h_reverse_coefficients[i]; + const i_t cstr_idx = rev_cstr[i]; + const f_t cstr_coeff = rev_coeff[i]; - f_t old_lhs = fj_cpu.h_lhs[cstr_idx]; + const f_t old_lhs = row_lhs[cstr_idx]; // Kahan compensated summation - f_t y = cstr_coeff * delta - fj_cpu.h_lhs_sumcomp[cstr_idx]; - f_t t = old_lhs + y; - fj_cpu.h_lhs_sumcomp[cstr_idx] = (t - old_lhs) - y; - fj_cpu.h_lhs[cstr_idx] = t; - f_t new_lhs = fj_cpu.h_lhs[cstr_idx]; - f_t old_cost = fj_cpu.view.excess_score(cstr_idx, old_lhs, c_lb, c_ub); - f_t new_cost = fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub); - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + const f_t y = cstr_coeff * delta - row_sumcomp[cstr_idx]; + const f_t t = old_lhs + y; + const f_t new_sumcomp = (t - old_lhs) - y; + row_sumcomp[cstr_idx] = new_sumcomp; + row_lhs[cstr_idx] = t; + + const f_t old_cost = fj_cpu.view.excess_score(cstr_idx, old_lhs, c_lb, c_ub); + const f_t new_cost = fj_cpu.view.excess_score(cstr_idx, t, c_lb, c_ub); + const f_t cstr_tolerance = row_tol[cstr_idx]; // trigger early lhs recomputation if the sumcomp term gets too large // to avoid large numerical errors - if (fabs(fj_cpu.h_lhs_sumcomp[cstr_idx]) > BIGVAL_THRESHOLD) - fj_cpu.trigger_early_lhs_recomputation = true; + if (fabs(new_sumcomp) > BIGVAL_THRESHOLD) fj_cpu.trigger_early_lhs_recomputation = true; + + const bool was_violated = fj_cpu.violated_constraints.contains(cstr_idx); + const bool now_violated = new_cost < -cstr_tolerance; + + // total_violations sums the excess over the violated set alone, so a row crossing the boundary + // contributes its whole cost rather than a difference. Kahan compensated, as h_lhs is: this is + // now the only place the total is maintained between refreshes. + const f_t viol_delta = + (now_violated ? new_cost : f_t{0}) - (was_violated ? old_cost : f_t{0}); + if (viol_delta != f_t{0}) { + const f_t viol_old = fj_cpu.total_violations; + const f_t viol_y = viol_delta - fj_cpu.total_violations_sumcomp; + const f_t viol_t = viol_old + viol_y; + fj_cpu.total_violations_sumcomp = (viol_t - viol_old) - viol_y; + fj_cpu.total_violations = viol_t; + } - if (new_cost < -cstr_tolerance && !fj_cpu.violated_constraints.count(cstr_idx)) { + if (now_violated && !was_violated) { fj_cpu.violated_constraints.insert(cstr_idx); - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 1, ""); - fj_cpu.satisfied_constraints.erase(cstr_idx); - } else if (!(new_cost < -cstr_tolerance) && fj_cpu.violated_constraints.count(cstr_idx)) { - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 0, ""); - fj_cpu.violated_constraints.erase(cstr_idx); + cuopt_assert(fj_cpu.satisfied_constraints.contains(cstr_idx), ""); + fj_cpu.satisfied_constraints.remove(cstr_idx); + } else if (!now_violated && was_violated) { + cuopt_assert(!fj_cpu.satisfied_constraints.contains(cstr_idx), ""); + fj_cpu.violated_constraints.remove(cstr_idx); fj_cpu.satisfied_constraints.insert(cstr_idx); } cuopt_assert(isfinite(delta), "delta should be finite"); - cuopt_assert(isfinite(fj_cpu.h_lhs[cstr_idx]), "assignment should be finite"); + cuopt_assert(isfinite(t), "assignment should be finite"); // Invalidate related cached move scores - auto [relvar_offset_begin, relvar_offset_end] = - range_for_constraint(fj_cpu, cstr_idx); - for (auto i = relvar_offset_begin; i < relvar_offset_end; i++) { - fj_cpu.cached_mtm_moves[i].first = 0; - } - } - - if (previous_viol > 0 && fj_cpu.violated_constraints.empty()) { - fj_cpu.last_feasible_entrance_iter = fj_cpu.iterations; + fj_cpu.h_cstr_version[cstr_idx]++; } // update the assignment and objective proper fj_cpu.h_assignment[var_idx] = new_val; - fj_cpu.h_incumbent_objective += fj_cpu.h_obj_coeffs[var_idx] * delta; - if (fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective && - fj_cpu.violated_constraints.empty()) { - // recompute the LHS values to cancel out accumulation errors, then check if feasibility remains - recompute_lhs(fj_cpu); + // The clamp above passes a NaN straight through, and every comparison against one is false. + cuopt_assert(fj_cpu.view.pb.check_variable_within_bounds(var_idx, new_val), + "apply_move left the variable bounds"); + + // Kahan compensated summation, as for h_lhs. The incumbent objective is reported as-is, so it + // cannot carry the drift of a long uncompensated chain of deltas. + const f_t obj_old = fj_cpu.h_incumbent_objective; + const f_t obj_y = fj_cpu.h_obj_coeffs[var_idx] * delta - fj_cpu.h_objective_sumcomp; + const f_t obj_t = obj_old + obj_y; + fj_cpu.h_objective_sumcomp = (obj_t - obj_old) - obj_y; + fj_cpu.h_incumbent_objective = obj_t; - if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { - cuopt_assert(fj_cpu.satisfied_constraints.size() == fj_cpu.view.pb.n_constraints, ""); - fj_cpu.h_best_objective = - fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; - fj_cpu.h_best_assignment = fj_cpu.h_assignment; - fj_cpu.iterations_since_best = 0; - CUOPT_LOG_TRACE( - "%sCPUFJ: new best objective: %g", fj_cpu.log_prefix.c_str(), fj_cpu.h_incumbent_objective); - if (fj_cpu.improvement_callback) { - double current_work_units = fj_cpu.work_units_elapsed.load(std::memory_order_acquire); - fj_cpu.improvement_callback( - fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); - } - fj_cpu.feasible_found = true; + if (fj_cpu.h_incumbent_objective < fj_cpu.h_best_objective && + fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { + cuopt_assert(fj_cpu.satisfied_constraints.size() == fj_cpu.view.pb.n_constraints, ""); + fj_cpu.h_best_objective = + fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; + fj_cpu.h_best_assignment = fj_cpu.h_assignment; + fj_cpu.iterations_since_best = 0; + // DEBUG, and reporting the stored best rather than the pre-epsilon incumbent, + // so it matches the binary path and the end-of-solve incumbent audit. + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); + if (fj_cpu.improvement_callback) { + double current_work_units = fj_cpu.work_units_elapsed.load(std::memory_order_acquire); + fj_cpu.improvement_callback( + fj_cpu.h_incumbent_objective, fj_cpu.h_assignment, current_work_units); + } + fj_cpu.feasible_found = true; + // The true objective of the assignment, not the epsilon-reduced threshold stored above, so + // another lane comparing against it is not misled into adopting something no better. + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + } + // Counteract the smooth_weights decay for a lane that is actively improving, and hold the + // weight at a scale where base_feas_sum still registers against it. + if (fj_cpu.h_objective_weight > 0) { + fj_cpu.h_objective_weight = + min((f_t)fj_obj_weight_incumbent_cap, + fj_cpu.h_objective_weight + (f_t)fj_obj_weight_incumbent_bump); + // The weight enters every score, and row versions cannot see it move. + retire_var_best_moves(fj_cpu); } } @@ -850,9 +1547,46 @@ static void apply_move(fj_cpu_climber_t& fj_cpu, // CUOPT_LOG_TRACE("CPU: tabu noinc_until: %d\n", fj_cpu.h_tabu_noinc_until[var_idx]); } - std::fill(fj_cpu.flip_move_computed.begin(), fj_cpu.flip_move_computed.end(), false); - std::fill(fj_cpu.var_bitmap.begin(), fj_cpu.var_bitmap.end(), false); - fj_cpu.iter_mtm_vars.clear(); + ++fj_cpu.flip_move_epoch; +} + +// Tightest value the rows of a certified epigraph variable imply. Satisfies all of them at once and +// leaves the objective as small as they allow, which is why it is sound from an infeasible point. +template +static f_t project_epigraph_variable(fj_cpu_climber_t& fj_cpu, i_t var_idx) +{ + cuopt_assert(fj_cpu.epigraph_push[var_idx] != 0, "variable is not a certified epigraph variable"); + const bool push_up = fj_cpu.epigraph_push[var_idx] > 0; + const f_t current = fj_cpu.h_assignment[var_idx]; + const auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + f_t target = push_up ? get_lower(bounds) : get_upper(bounds); + + auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + const size_t nnz_read = (size_t)(offset_end - offset_begin); + fj_cpu.h_reverse_constraints.byte_loads += nnz_read * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_read * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_read * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_read * sizeof(f_t); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + + for (i_t p = offset_begin; p < offset_end; ++p) { + const f_t coeff = rev_coeff[p]; + if (coeff == f_t{0}) continue; + const auto [c_lb, c_ub] = cstr_bounds[p]; + const f_t rest = row_lhs[rev_cstr[p]] - coeff * current; + const f_t bound = ((coeff > f_t{0}) == push_up) ? c_lb : c_ub; + const f_t implied = (bound - rest) / coeff; + if (!isfinite(implied)) continue; + target = push_up ? max(target, implied) : min(target, implied); + } + + target = std::min(std::max(target, get_lower(bounds)), get_upper(bounds)); + cuopt_assert(isfinite(target), "epigraph projection is not finite"); + return target; } template @@ -866,36 +1600,49 @@ static thrust::tuple find_mtm_move( fj_move_t best_move = fj_move_t{-1, 0}; fj_staged_score_t best_score = fj_staged_score_t::invalid(); - // collect all the variables that are involved in the target constraints + ++fj_cpu.n_mtm_calls; + + // Each row contributes at most its share of the sampling budget. The gate below sits inside the + // walk, so an uncapped wide row is walked in full whatever the budget says. + const i_t per_row_cap = + std::max(1, fj_cpu.nnz_samples / std::max(1, (i_t)target_cstrs.size())); + + i_t entries = 0; for (size_t cstr_idx : target_cstrs) { auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - for (auto i = offset_begin; i < offset_end; i++) { - i_t var_idx = fj_cpu.h_variables[i]; - if (fj_cpu.var_bitmap[var_idx]) continue; - fj_cpu.iter_mtm_vars.push_back(var_idx); - fj_cpu.var_bitmap[var_idx] = true; - } - } - // estimate the amount of nnzs to consider - i_t nnz_sum = 0; - for (auto var_idx : fj_cpu.iter_mtm_vars) { - auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); - nnz_sum += offset_end - offset_begin; + const i_t width = offset_end - offset_begin; + entries += std::min(width, per_row_cap); + fj_cpu.mtm_entries_capped += (int64_t)std::max(0, width - per_row_cap); } + fj_cpu.mtm_row_entries += (int64_t)entries; + + // The exact sum over the candidate variables costs one random offset read each to set a single + // sampling rate. The mean reverse degree estimates it in constant time. + const f_t mean_reverse_degree = + (f_t)fj_cpu.h_coefficients.size() / (f_t)std::max(1, fj_cpu.view.pb.n_variables); + const f_t nnz_sum = (f_t)entries * mean_reverse_degree; f_t nnz_pick_probability = 1; - if (nnz_sum > fj_cpu.nnz_samples) nnz_pick_probability = (f_t)fj_cpu.nnz_samples / nnz_sum; + if (nnz_sum > (f_t)fj_cpu.nnz_samples) nnz_pick_probability = (f_t)fj_cpu.nnz_samples / nnz_sum; for (size_t cstr_idx : target_cstrs) { - auto c_lb = fj_cpu.h_cstr_lb[cstr_idx]; - auto c_ub = fj_cpu.h_cstr_ub[cstr_idx]; - f_t cstr_tol = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tol = fj_cpu.h_cstr_tolerance[cstr_idx]; cuopt_assert(cstr_idx < fj_cpu.h_cstr_lb.size(), "cstr_idx is out of bounds"); auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - for (auto i = offset_begin; i < offset_end; i++) { + const i_t width = offset_end - offset_begin; + const i_t visit = std::min(width, per_row_cap); + const i_t start = visit == width + ? offset_begin + : offset_begin + (i_t)(rng.next_u32() % (uint32_t)width); + for (i_t q = 0, i = start; q < visit; + ++q, i = (i + 1 == offset_end ? offset_begin : i + 1)) { // early cached check - if (auto& cached_move = fj_cpu.cached_mtm_moves[i]; cached_move.first != 0) { + cuopt_assert(fj_cpu.cached_mtm_moves_version[i] <= fj_cpu.h_cstr_version[cstr_idx], + "cached move newer than its constraint"); + if (auto& cached_move = fj_cpu.cached_mtm_moves[i]; + cached_move.first != 0 && + fj_cpu.cached_mtm_moves_version[i] == fj_cpu.h_cstr_version[cstr_idx]) { if (best_score < cached_move.second) { auto var_idx = fj_cpu.h_variables[i]; if (check_variable_within_bounds( @@ -922,23 +1669,23 @@ static thrust::tuple find_mtm_move( // Special case for binary variables if (fj_cpu.h_is_binary_variable[var_idx]) { - if (fj_cpu.flip_move_computed[var_idx]) continue; - fj_cpu.flip_move_computed[var_idx] = true; - new_val = 1 - val; + if (fj_cpu.flip_move_stamp[var_idx] == fj_cpu.flip_move_epoch) continue; + fj_cpu.flip_move_stamp[var_idx] = fj_cpu.flip_move_epoch; + new_val = 1 - val; } else { auto cstr_coeff = fj_cpu.h_coefficients[i]; f_t c_lb = fj_cpu.h_cstr_lb[cstr_idx]; f_t c_ub = fj_cpu.h_cstr_ub[cstr_idx]; auto [delta, sign, slack, cstr_tolerance] = - get_mtm_for_constraint(fj_cpu.view, - var_idx, + get_mtm_for_constraint(var_idx, cstr_idx, cstr_coeff, c_lb, c_ub, fj_cpu.h_assignment, - fj_cpu.h_lhs); + fj_cpu.h_lhs, + cstr_tol); if (is_integer_var(fj_cpu, var_idx)) { new_val = cstr_coeff * sign > 0 ? floor(val + delta + fj_cpu.view.pb.tolerances.integrality_tolerance) @@ -965,12 +1712,14 @@ static thrust::tuple find_mtm_move( cuopt_assert(move.var_idx < fj_cpu.h_assignment.size(), "move.var_idx is out of bounds"); cuopt_assert(move.var_idx >= 0, "move.var_idx is not positive"); - auto [score, infeasibility] = compute_score(fj_cpu, var_idx, delta); - fj_cpu.cached_mtm_moves[i] = std::make_pair(delta, score); + auto [score, infeasibility] = compute_score(fj_cpu, var_idx, delta); + fj_cpu.cached_mtm_moves[i] = std::make_pair(delta, score); + fj_cpu.cached_mtm_moves_version[i] = fj_cpu.h_cstr_version[cstr_idx]; fj_cpu.miss_count++; // reject this move if it would increase the target variable to a numerically unstable value if (fj_cpu.view.move_numerically_stable( val, new_val, infeasibility, fj_cpu.total_violations)) { + record_var_best_move(fj_cpu, var_idx, score, delta); if (best_score < score) { best_score = score; best_move = move; @@ -984,6 +1733,7 @@ static thrust::tuple find_mtm_move( fj_cpu.h_best_objective < std::numeric_limits::infinity() && fj_cpu.h_incumbent_objective >= fj_cpu.h_best_objective + fj_cpu.settings.parameters.breakthrough_move_epsilon) { + cuopt_func_call(audit_breakthrough_inputs(fj_cpu)); for (auto var_idx : fj_cpu.h_objective_vars) { f_t old_val = fj_cpu.h_assignment[var_idx]; f_t new_val = get_breakthrough_move(fj_cpu.view, var_idx); @@ -1006,6 +1756,7 @@ static thrust::tuple find_mtm_move( if (fj_cpu.view.move_numerically_stable( old_val, new_val, infeasibility, fj_cpu.total_violations)) { + record_var_best_move(fj_cpu, var_idx, score, delta); if (best_score < score) { best_score = score; best_move = move; @@ -1017,6 +1768,27 @@ static thrust::tuple find_mtm_move( return thrust::make_tuple(best_move, best_score); } +template +static void sample_with_replacement(const host_contiguous_set_t& pool, + i_t sample_size, + uint64_t seed, + std::vector& out) +{ + cuopt_assert(sample_size > 0, "invalid sample size"); + out.clear(); + const i_t pool_size = pool.size(); + if (pool_size == 0) { return; } + if (pool_size <= sample_size) { + out.assign(pool.begin(), pool.end()); + return; + } + out.reserve(sample_size); + cuopt::pcgenerator_t rng(seed); + for (i_t i = 0; i < sample_size; ++i) { + out.push_back(pool.contents[rng.next_u32() % (uint32_t)pool_size]); + } +} + template static thrust::tuple find_mtm_move_viol( fj_cpu_climber_t& fj_cpu, i_t sample_size = 100, bool localmin = false) @@ -1025,12 +1797,10 @@ static thrust::tuple find_mtm_move_viol( CPUFJ_NVTX_RANGE("CPUFJ::find_mtm_move_viol"); std::vector sampled_cstrs; - sampled_cstrs.reserve(sample_size); - std::sample(fj_cpu.violated_constraints.begin(), - fj_cpu.violated_constraints.end(), - std::back_inserter(sampled_cstrs), - sample_size, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + sample_with_replacement(fj_cpu.violated_constraints, + sample_size, + fj_cpu.settings.seed + fj_cpu.iterations, + sampled_cstrs); return find_mtm_move(fj_cpu, sampled_cstrs, localmin); } @@ -1043,12 +1813,10 @@ static thrust::tuple find_mtm_move_sat( CPUFJ_NVTX_RANGE("CPUFJ::find_mtm_move_sat"); std::vector sampled_cstrs; - sampled_cstrs.reserve(sample_size); - std::sample(fj_cpu.satisfied_constraints.begin(), - fj_cpu.satisfied_constraints.end(), - std::back_inserter(sampled_cstrs), - sample_size, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + sample_with_replacement(fj_cpu.satisfied_constraints, + sample_size, + fj_cpu.settings.seed + fj_cpu.iterations, + sampled_cstrs); return find_mtm_move(fj_cpu, sampled_cstrs); } @@ -1058,6 +1826,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) { CPUFJ_NVTX_RANGE("CPUFJ::recompute_lhs"); cuopt_assert(fj_cpu.h_lhs.size() == fj_cpu.view.pb.n_constraints, "h_lhs size mismatch"); + ++fj_cpu.n_lhs_recompute_total; // clamp to var bounds - defensive; apply_move should already have clamped appropriately for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { @@ -1068,11 +1837,10 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_cpu.violated_constraints.clear(); fj_cpu.satisfied_constraints.clear(); - fj_cpu.total_violations = 0; + fj_cpu.total_violations = 0; + fj_cpu.total_violations_sumcomp = 0; for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; ++cstr_idx) { auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); - auto c_lb = fj_cpu.h_cstr_lb[cstr_idx]; - auto c_ub = fj_cpu.h_cstr_ub[cstr_idx]; auto delta_it = thrust::make_transform_iterator(thrust::make_counting_iterator(0), [&fj_cpu](i_t j) { return fj_cpu.h_coefficients[j] * fj_cpu.h_assignment[fj_cpu.h_variables[j]]; @@ -1081,7 +1849,7 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) fj_kahan_babushka_neumaier_sum(delta_it + offset_begin, delta_it + offset_end); fj_cpu.h_lhs_sumcomp[cstr_idx] = 0; - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tolerance = fj_cpu.h_cstr_tolerance[cstr_idx]; f_t new_cost = fj_cpu.view.excess_score(cstr_idx, fj_cpu.h_lhs[cstr_idx]); if (new_cost < -cstr_tolerance) { fj_cpu.violated_constraints.insert(cstr_idx); @@ -1094,6 +1862,130 @@ static void recompute_lhs(fj_cpu_climber_t& fj_cpu) // compute incumbent objective fj_cpu.h_incumbent_objective = thrust::inner_product( fj_cpu.h_assignment.begin(), fj_cpu.h_assignment.end(), fj_cpu.h_obj_coeffs.begin(), 0.); + fj_cpu.h_objective_sumcomp = 0; +} + + +// Candidate draws per 2-opt lift search. +constexpr int32_t fj_2opt_candidates = 32; + +// True when flipping both variables leaves every row they touch satisfied. Both reverse ranges are +// row-ascending, so a merge handles rows containing both variables with their joint delta. +template +static bool paired_flip_keeps_feasible( + fj_cpu_climber_t& fj_cpu, i_t var1, f_t delta1, i_t var2, f_t delta2) +{ + const auto range1 = reverse_range_for_var(fj_cpu, var1); + const auto range2 = reverse_range_for_var(fj_cpu, var2); + i_t i = range1.first, ie = range1.second; + i_t j = range2.first, je = range2.second; + + while (i < ie || j < je) { + const i_t r1 = i < ie ? (i_t)fj_cpu.h_reverse_constraints[i] : std::numeric_limits::max(); + const i_t r2 = j < je ? (i_t)fj_cpu.h_reverse_constraints[j] : std::numeric_limits::max(); + const i_t r = r1 < r2 ? r1 : r2; + + f_t change = 0; + f_t c_lb = 0; + f_t c_ub = 0; + if (r1 == r) { + auto [lb, ub] = fj_cpu.cached_cstr_bounds[i].get(); + c_lb = lb; + c_ub = ub; + change += (f_t)fj_cpu.h_reverse_coefficients[i] * delta1; + ++i; + } + if (r2 == r) { + auto [lb, ub] = fj_cpu.cached_cstr_bounds[j].get(); + c_lb = lb; + c_ub = ub; + change += (f_t)fj_cpu.h_reverse_coefficients[j] * delta2; + ++j; + } + + const f_t new_lhs = fj_cpu.h_lhs[r] + (change - fj_cpu.h_lhs_sumcomp[r]); + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < -(f_t)fj_cpu.h_cstr_tolerance[r]) + return false; + } + return true; +} + +template +static thrust::tuple find_lift_2opt_move( + fj_cpu_climber_t& fj_cpu) +{ + timing_raii_t timer(fj_cpu.find_lift_move_times); + CPUFJ_NVTX_RANGE("CPUFJ::find_lift_2opt_move"); + cuopt_assert(fj_cpu.violated_constraints.empty(), "lift moves require a feasible incumbent"); + + fj_move_t best_first = fj_move_t{-1, 0}; + fj_move_t best_second = fj_move_t{-1, 0}; + fj_staged_score_t best_score = fj_staged_score_t::zero(); + f_t best_improvement = 0; + + const i_t n_obj = (i_t)fj_cpu.h_objective_vars.size(); + if (n_obj == 0) return thrust::make_tuple(best_first, best_second, best_score); + + raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); + const i_t n_draws = n_obj < fj_2opt_candidates ? n_obj : fj_2opt_candidates; + + for (i_t t = 0; t < n_draws; ++t) { + const i_t var1 = fj_cpu.h_objective_vars[rng.next_u32() % (uint32_t)n_obj]; + if (!fj_cpu.h_is_binary_variable[var1]) continue; + + const f_t coeff1 = fj_cpu.h_obj_coeffs[var1]; + const f_t val1 = fj_cpu.h_assignment[var1]; + const f_t delta1 = round(1.0 - 2 * val1); + if (delta1 * coeff1 >= 0) continue; + if (tabu_check(fj_cpu, var1, delta1)) continue; + + // Breaking nothing is the single-flip lift's job; breaking several rows cannot be repaired by + // one companion. + const auto range1 = reverse_range_for_var(fj_cpu, var1); + i_t broken = -1; + bool multiple = false; + for (i_t k = range1.first; k < range1.second && !multiple; ++k) { + auto [c_lb, c_ub] = fj_cpu.cached_cstr_bounds[k].get(); + const i_t r = fj_cpu.h_reverse_constraints[k]; + const f_t new_lhs = fj_cpu.h_lhs[r] + ((f_t)fj_cpu.h_reverse_coefficients[k] * delta1 - + fj_cpu.h_lhs_sumcomp[r]); + if (fj_cpu.view.excess_score(r, new_lhs, c_lb, c_ub) < -(f_t)fj_cpu.h_cstr_tolerance[r]) { + if (broken >= 0) + multiple = true; + else + broken = r; + } + } + if (multiple || broken < 0) continue; + + const auto row = range_for_constraint(fj_cpu, broken); + for (i_t k = row.first; k < row.second; ++k) { + const i_t var2 = fj_cpu.h_variables[k]; + if (var2 == var1) continue; + if (!fj_cpu.h_is_binary_variable[var2]) continue; + + const f_t coeff2 = fj_cpu.h_obj_coeffs[var2]; + const f_t val2 = fj_cpu.h_assignment[var2]; + const f_t delta2 = round(1.0 - 2 * val2); + const f_t combined = delta1 * coeff1 + delta2 * coeff2; + if (combined >= 0) continue; + if (tabu_check(fj_cpu, var2, delta2)) continue; + if (!paired_flip_keeps_feasible(fj_cpu, var1, delta1, var2, delta2)) continue; + + // Both lift operators rank on the objective gain in its own units: the score quantization + // used elsewhere counts weights, so rounding a gain below 0.5 into it discards the move. + const f_t improvement = -combined; + if (improvement > best_improvement) { + best_improvement = improvement; + best_score.base = 1; // sign only, never compared against another operator's score + best_first = fj_move_t{var1, delta1}; + best_second = fj_move_t{var2, delta2}; + } + } + } + cuopt_assert((best_first.var_idx < 0) == (best_improvement <= 0), + "pair and score must agree on whether a move was found"); + return thrust::make_tuple(best_first, best_second, best_score); } template @@ -1105,6 +1997,7 @@ static thrust::tuple find_lift_move( fj_move_t best_move = fj_move_t{-1, 0}; fj_staged_score_t best_score = fj_staged_score_t::zero(); + f_t best_improvement = 0; for (auto var_idx : fj_cpu.h_objective_vars) { cuopt_assert(var_idx < fj_cpu.h_obj_coeffs.size(), "var_idx is out of bounds"); @@ -1122,6 +2015,40 @@ static thrust::tuple find_lift_move( delta = round(1.0 - 2 * val); // flip move wouldn't improve if (delta * obj_coeff >= 0) continue; + + auto [offset_begin, offset_end] = reverse_range_for_var(fj_cpu, var_idx); + + const i_t* const rev_cstr = fj_cpu.view.pb.reverse_constraints.data(); + const f_t* const rev_coeff = fj_cpu.view.pb.reverse_coefficients.data(); + const f_t* const row_lhs = fj_cpu.view.incumbent_lhs.data(); + const f_t* const row_sumcomp = fj_cpu.view.incumbent_lhs_sumcomp.data(); + const f_t* const row_tol = fj_cpu.h_cstr_tolerance.data(); + const std::pair* const cstr_bounds = fj_cpu.cached_cstr_bounds.data(); + + bool breaks_a_row = false; + i_t scanned = 0; + for (i_t j = offset_begin; j < offset_end; ++j) { + ++scanned; + const auto [c_lb, c_ub] = cstr_bounds[j]; + const i_t cstr_idx = rev_cstr[j]; + const f_t cstr_coeff = rev_coeff[j]; + const f_t lhs = row_lhs[cstr_idx]; + const f_t sumcomp = row_sumcomp[cstr_idx]; + const f_t new_lhs = lhs + (cstr_coeff * delta - sumcomp); + if (fj_cpu.view.excess_score(cstr_idx, new_lhs, c_lb, c_ub) < -row_tol[cstr_idx]) { + breaks_a_row = true; + break; + } + } + + const size_t nnz_scanned = (size_t)scanned; + fj_cpu.h_reverse_constraints.byte_loads += nnz_scanned * sizeof(i_t); + fj_cpu.h_reverse_coefficients.byte_loads += nnz_scanned * sizeof(f_t); + fj_cpu.cached_cstr_bounds.byte_loads += nnz_scanned * sizeof(std::pair); + fj_cpu.h_lhs.byte_loads += nnz_scanned * sizeof(f_t); + fj_cpu.h_lhs_sumcomp.byte_loads += nnz_scanned * sizeof(f_t); + + if (breaks_a_row) continue; } else { f_t lfd_lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()) - val; f_t lfd_ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()) - val; @@ -1131,7 +2058,7 @@ static thrust::tuple find_lift_move( auto cstr_coeff = fj_cpu.h_reverse_coefficients[j]; f_t c_lb = fj_cpu.h_cstr_lb[cstr_idx]; f_t c_ub = fj_cpu.h_cstr_ub[cstr_idx]; - f_t cstr_tolerance = fj_cpu.view.get_corrected_tolerance(cstr_idx, c_lb, c_ub); + f_t cstr_tolerance = fj_cpu.h_cstr_tolerance[cstr_idx]; cuopt_assert(c_lb <= c_ub, "invalid bounds"); cuopt_assert(fj_cpu.view.cstr_satisfied(cstr_idx, fj_cpu.h_lhs[cstr_idx]), "cstr should be satisfied"); @@ -1191,59 +2118,180 @@ static thrust::tuple find_lift_move( cuopt_assert(delta * obj_coeff < 0, "lift move doesn't improve the objective!"); - // get the score - auto move = fj_move_t{var_idx, delta}; - fj_staged_score_t score = fj_staged_score_t::zero(); - f_t obj_score = -1 * obj_coeff * delta; // negated to turn this into a positive score - score.base = round(obj_score); - - if (best_score < score) { - best_score = score; - best_move = move; + const f_t improvement = -obj_coeff * delta; + if (improvement > best_improvement) { + best_improvement = improvement; + best_score.base = 1; + best_move = fj_move_t{var_idx, delta}; } } + cuopt_assert((best_move.var_idx < 0) == (best_improvement <= 0), + "move and score must agree on whether a move was found"); return thrust::make_tuple(best_move, best_score); } +// Draws a uniform in-bounds value, rounded and re-clamped for integer variables. +template +static void randomize_variable(fj_cpu_climber_t& fj_cpu, + i_t var_idx, + raft::random::PCGenerator& rng) +{ + f_t lb = std::max(get_lower(fj_cpu.h_var_bounds[var_idx].get()), -1e7); + f_t ub = std::min(get_upper(fj_cpu.h_var_bounds[var_idx].get()), 1e7); + f_t val = lb + (ub - lb) * rng.next_double(); + if (is_integer_var(fj_cpu, var_idx)) { + lb = std::ceil(lb); + ub = std::floor(ub); + val = std::round(val); + val = std::min(std::max(val, lb), ub); + } + + cuopt_assert((check_variable_within_bounds(fj_cpu, var_idx, val)), + "value is out of bounds"); + fj_cpu.h_assignment[var_idx] = val; +} + template static void perturb(fj_cpu_climber_t& fj_cpu) { CPUFJ_NVTX_RANGE("CPUFJ::perturb"); + if (fj_cpu.feasible_found) { + cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_assignment.size(), + "incumbent_assignment span would be invalidated"); + fj_cpu.h_assignment = fj_cpu.h_best_assignment; + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->adopt(fj_cpu.h_best_objective, fj_cpu.h_assignment); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "shared adopt")); + } + } + // select N variables, assign them a random value between their bounds std::vector sampled_vars; std::sample(fj_cpu.h_objective_vars.begin(), fj_cpu.h_objective_vars.end(), std::back_inserter(sampled_vars), - 2, - std::mt19937(fj_cpu.settings.seed + fj_cpu.iterations)); + std::max(1, fj_cpu.perturb_vars), + fj_cpu.rng); raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); - for (auto var_idx : sampled_vars) { - f_t lb = std::max(get_lower(fj_cpu.h_var_bounds[var_idx].get()), -1e7); - f_t ub = std::min(get_upper(fj_cpu.h_var_bounds[var_idx].get()), 1e7); - f_t val = lb + (ub - lb) * rng.next_double(); - if (is_integer_var(fj_cpu, var_idx)) { - lb = std::ceil(lb); - ub = std::floor(ub); - val = std::round(val); - val = std::min(std::max(val, lb), ub); - } - - cuopt_assert((check_variable_within_bounds(fj_cpu, var_idx, val)), - "value is out of bounds"); - fj_cpu.h_assignment[var_idx] = val; - } + for (auto var_idx : sampled_vars) + randomize_variable(fj_cpu, var_idx, rng); + ++fj_cpu.n_lhs_recompute_perturb; recompute_lhs(fj_cpu); + retire_var_best_moves(fj_cpu); } template -static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, - solution_t& solution, +static void reset_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + fj_cpu.h_best_infeasible_assignment.clear(); + fj_cpu.best_infeasible_severity = std::numeric_limits::infinity(); + fj_cpu.checkpoint_severity = std::numeric_limits::infinity(); + fj_cpu.iters_since_infeasible_improve = 0; +} + +template +static void invalidate_mtm_cache(fj_cpu_climber_t& fj_cpu) +{ + ++fj_cpu.n_mtm_cache_invalidations; + for (size_t c = 0; c < fj_cpu.h_cstr_version.size(); ++c) + fj_cpu.h_cstr_version[c]++; +} + +template +static void restart_from_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + cuopt_assert(fj_cpu.h_assignment.size() == fj_cpu.h_best_infeasible_assignment.size(), + "incumbent_assignment span would be invalidated"); + fj_cpu.h_assignment = fj_cpu.h_best_infeasible_assignment; + ++fj_cpu.n_lhs_recompute_restart; + recompute_lhs(fj_cpu); + invalidate_mtm_cache(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "checkpoint restore")); +} + +// Nonzeros per extra restart window, the cap on that, and how many windows a lane waits. +constexpr int32_t fj_restart_window_nnz_scale = 100000; +constexpr int32_t fj_restart_window_scale_max = 4; +constexpr int32_t fj_restart_window_multiple = 4; + +template +static void track_infeasible_checkpoint(fj_cpu_climber_t& fj_cpu) +{ + CPUFJ_NVTX_RANGE("CPUFJ::track_infeasible_checkpoint"); + if (fj_cpu.violated_constraints.empty()) { + reset_infeasible_checkpoint(fj_cpu); + return; + } + + const f_t severity = -fj_cpu.total_violations; + cuopt_assert(severity >= 0, "violation severity should be positive or zero"); + + if (severity < fj_cpu.best_infeasible_severity) { + fj_cpu.best_infeasible_severity = severity; + fj_cpu.iters_since_infeasible_improve = 0; + fj_cpu.restores_since_improvement = 0; + if (severity < fj_cpu.checkpoint_severity * fj_cpu.infeasible_checkpoint_refresh_ratio) { + fj_cpu.h_best_infeasible_assignment = fj_cpu.h_assignment; + fj_cpu.checkpoint_severity = severity; + ++fj_cpu.n_checkpoint_snapshots; + } + return; + } + + // A lane that has never crossed and has exhausted its restores abandons the basin outright. + if (!fj_cpu.feasible_found) { + const i_t nnz_scale = + 1 + (i_t)fj_cpu.h_coefficients.size() / fj_restart_window_nnz_scale; + const i_t capped = nnz_scale < fj_restart_window_scale_max ? nnz_scale + : fj_restart_window_scale_max; + if (fj_cpu.iters_since_infeasible_improve >= + fj_restart_window_multiple * fj_cpu.infeasible_restart_window * capped && + fj_cpu.restores_since_improvement >= fj_cpu.infeasible_restart_max_streak) { + raft::random::PCGenerator rng(fj_cpu.settings.seed + fj_cpu.iterations, 0, 0); + for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) + randomize_variable(fj_cpu, var_idx, rng); + + ++fj_cpu.n_lhs_recompute_restart; + recompute_lhs(fj_cpu); + invalidate_mtm_cache(fj_cpu); + reset_infeasible_checkpoint(fj_cpu); + fj_cpu.restores_since_improvement = 0; + cuopt_func_call(audit_assignment_bounds(fj_cpu, "randomized restart")); + + CUOPT_LOG_DEBUG("%sCPUFJ randomized restart at iteration %d", + fj_cpu.log_prefix.c_str(), + fj_cpu.iterations); + return; + } + } + + if (fj_cpu.restores_since_improvement >= fj_cpu.infeasible_restart_max_streak) return; + if (++fj_cpu.iters_since_infeasible_improve < fj_cpu.infeasible_restart_window) return; + if (severity <= fj_cpu.best_infeasible_severity * fj_cpu.infeasible_restart_degrade_ratio) return; + if (fj_cpu.h_best_infeasible_assignment.empty()) return; + + cuopt_assert(fj_cpu.checkpoint_severity >= fj_cpu.best_infeasible_severity, + "checkpoint cannot beat the best severity seen"); + + restart_from_infeasible_checkpoint(fj_cpu); + + ++fj_cpu.n_checkpoint_restores; + ++fj_cpu.restores_since_improvement; + if (fj_cpu.restores_since_improvement > fj_cpu.max_restores_since_improvement) + fj_cpu.max_restores_since_improvement = fj_cpu.restores_since_improvement; + fj_cpu.iters_since_infeasible_improve = 0; +} + +template +static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, + solution_t& solution, const std::vector& left_weights, const std::vector& right_weights, - f_t objective_weight) + f_t objective_weight, + const probing_cache_t* probing_cache) { auto& problem = *solution.problem_ptr; auto handle_ptr = solution.handle_ptr; @@ -1272,6 +2320,13 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, fj_cpu.h_is_binary_variable = cuopt::host_copy(problem.is_binary_variable, handle_ptr->get_stream()); fj_cpu.h_binary_indices = cuopt::host_copy(problem.binary_indices, handle_ptr->get_stream()); + fj_cpu.h_related_variables = + cuopt::host_copy(problem.related_variables, handle_ptr->get_stream()); + fj_cpu.h_related_variables_offsets = + cuopt::host_copy(problem.related_variables_offsets, handle_ptr->get_stream()); + fj_cpu.probing_cache = probing_cache; + fj_cpu.h_original_ids = problem.original_ids; + fj_cpu.h_reverse_original_ids = problem.reverse_original_ids; fj_cpu.h_cstr_left_weights = left_weights; fj_cpu.h_cstr_right_weights = right_weights; @@ -1296,6 +2351,106 @@ static void init_fj_cpu(fj_cpu_climber_t& fj_cpu, problem.tolerances); } +template +static void init_fj_cpu_from_template(fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + const std::vector& left_weights, + const std::vector& right_weights, + f_t objective_weight) +{ + const i_t n_variables = (i_t)tmpl.h_reverse_offsets.size() - 1; + const i_t n_constraints = (i_t)tmpl.h_offsets.size() - 1; + const i_t nnz = (i_t)tmpl.h_coefficients.size(); + + cuopt_assert(n_variables == tmpl.view.pb.n_variables, "template variable count mismatch"); + cuopt_assert(n_constraints == tmpl.view.pb.n_constraints, "template constraint count mismatch"); + cuopt_assert(nnz == tmpl.view.pb.nnz, "template nnz mismatch"); + cuopt_assert(left_weights.size() == static_cast(n_constraints), + "left weight size mismatch"); + cuopt_assert(right_weights.size() == static_cast(n_constraints), + "right weight size mismatch"); + + fj_cpu.view = typename fj_t::climber_data_t::view_t{}; + // Every span the host views cover is re-pointed at this climber's own arrays below. The rest of + // the problem view carries over from the template, which is also what makes this usable on + // climbers built without a problem_t at all. + fj_cpu.view.pb = tmpl.view.pb; + fj_cpu.pb_ptr = tmpl.pb_ptr; + + fj_cpu.h_reverse_coefficients = tmpl.h_reverse_coefficients; + fj_cpu.h_reverse_constraints = tmpl.h_reverse_constraints; + fj_cpu.h_reverse_offsets = tmpl.h_reverse_offsets; + fj_cpu.h_coefficients = tmpl.h_coefficients; + fj_cpu.h_offsets = tmpl.h_offsets; + fj_cpu.h_variables = tmpl.h_variables; + fj_cpu.h_obj_coeffs = tmpl.h_obj_coeffs; + fj_cpu.h_var_bounds = tmpl.h_var_bounds; + fj_cpu.h_cstr_lb = tmpl.h_cstr_lb; + fj_cpu.h_cstr_ub = tmpl.h_cstr_ub; + fj_cpu.h_var_types = tmpl.h_var_types; + fj_cpu.h_is_binary_variable = tmpl.h_is_binary_variable; + fj_cpu.h_binary_indices = tmpl.h_binary_indices; + + fj_cpu.h_cstr_left_weights = left_weights; + fj_cpu.h_cstr_right_weights = right_weights; + fj_cpu.max_weight = 1.0; + fj_cpu.h_objective_weight = objective_weight; + fj_cpu.h_assignment = tmpl.h_assignment; + fj_cpu.h_best_assignment = tmpl.h_assignment; + fj_cpu.h_tabu_nodec_until.resize(n_variables, 0); + fj_cpu.h_tabu_noinc_until.resize(n_variables, 0); + fj_cpu.h_tabu_lastdec.resize(n_variables, 0); + fj_cpu.h_tabu_lastinc.resize(n_variables, 0); + fj_cpu.iterations = 0; + + finalize_fj_cpu_host_initialization_from_template(fj_cpu, + tmpl, + n_variables, + n_constraints, + tmpl.n_integer_vars, + nnz, + tmpl.view.pb.tolerances); +} + +// Certifies the epigraph variables: continuous, in the objective, and appearing in every one of +// their rows only on the side the objective pulls away from, with that direction unbounded. +template +static void certify_epigraph_variables(fj_cpu_climber_t& fj_cpu, i_t n_variables) +{ + fj_cpu.epigraph_push.assign(n_variables, 0); + fj_cpu.epigraph_vars.clear(); + + for (i_t var = 0; var < n_variables; ++var) { + if (is_integer_var(fj_cpu, var)) continue; + const f_t obj_coeff = fj_cpu.h_obj_coeffs[var]; + if (obj_coeff == f_t{0}) continue; + + const auto [begin, end] = reverse_range_for_var(fj_cpu, var); + if (begin == end) continue; + + // A positive coefficient is minimised by pushing the variable down, so its rows must be the + // only thing holding it up, and it must be free to rise as far as they demand. + const bool push_up = obj_coeff > f_t{0}; + const auto bounds = fj_cpu.h_var_bounds[var].get(); + if (isfinite(push_up ? get_upper(bounds) : get_lower(bounds))) continue; + + bool certified = true; + for (i_t p = begin; p < end && certified; ++p) { + const i_t row = fj_cpu.h_reverse_constraints[p]; + const f_t coeff = fj_cpu.h_reverse_coefficients[p]; + const bool has_lb = isfinite((f_t)fj_cpu.h_cstr_lb[row]); + const bool has_ub = isfinite((f_t)fj_cpu.h_cstr_ub[row]); + if (coeff == f_t{0}) continue; + certified = push_up ? ((coeff > 0 && has_lb && !has_ub) || (coeff < 0 && has_ub && !has_lb)) + : ((coeff > 0 && has_ub && !has_lb) || (coeff < 0 && has_lb && !has_ub)); + } + if (!certified) continue; + + fj_cpu.epigraph_push[var] = push_up ? 1 : -1; + fj_cpu.epigraph_vars.push_back(var); + } +} + template static void set_host_data_view( fj_cpu_climber_t& fj_cpu, @@ -1339,7 +2494,7 @@ static void set_host_data_view( } template -void finalize_fj_cpu_host_initialization( +static void wire_fj_cpu_host_views( fj_cpu_climber_t& fj_cpu, i_t n_variables, i_t n_constraints, @@ -1347,8 +2502,6 @@ void finalize_fj_cpu_host_initialization( i_t nnz, const typename mip_solver_settings_t::tolerances_t& tolerances) { - raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization"); - cuopt_assert(n_variables >= 0, "invalid variable count"); cuopt_assert(n_constraints >= 0, "invalid constraint count"); cuopt_assert(fj_cpu.h_offsets.size() == static_cast(n_constraints + 1), @@ -1382,6 +2535,39 @@ void finalize_fj_cpu_host_initialization( fj_cpu.view.best_objective = &fj_cpu.h_best_objective; fj_cpu.view.settings = &fj_cpu.settings; + fj_cpu.h_best_objective = +std::numeric_limits::infinity(); + + // nnz count + fj_cpu.cached_mtm_moves.resize(fj_cpu.h_coefficients.size(), + std::make_pair(0, fj_staged_score_t::zero())); + fj_cpu.cached_mtm_moves_version.assign(fj_cpu.h_coefficients.size(), -1); + fj_cpu.h_cstr_version.assign(n_constraints, 0); + + fj_cpu.flip_move_stamp.assign(n_variables, 0); + fj_cpu.flip_move_epoch = 1; + + fj_cpu.h_cstr_tolerance.resize(n_constraints); + for (i_t row = 0; row < n_constraints; ++row) { + fj_cpu.h_cstr_tolerance[row] = + fj_cpu.view.get_corrected_tolerance(row, fj_cpu.h_cstr_lb[row], fj_cpu.h_cstr_ub[row]); + } + + certify_epigraph_variables(fj_cpu, n_variables); +} + +template +void finalize_fj_cpu_host_initialization( + fj_cpu_climber_t& fj_cpu, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances) +{ + raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization"); + + wire_fj_cpu_host_views(fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + fj_cpu.h_objective_vars.resize(n_variables); auto end = std::copy_if( thrust::counting_iterator(0), @@ -1391,12 +2577,20 @@ void finalize_fj_cpu_host_initialization( fj_cpu.h_objective_vars.resize(end - fj_cpu.h_objective_vars.begin()); fj_cpu.view.objective_vars = raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); + // get_breakthrough_move divides by the coefficient of every variable in here. + for ([[maybe_unused]] auto var_idx : fj_cpu.h_objective_vars) { + cuopt_assert(fj_cpu.h_obj_coeffs[var_idx] != f_t{0}, "null coefficient in the objective vars"); + cuopt_assert(isfinite((f_t)fj_cpu.h_obj_coeffs[var_idx]), "non-finite objective coefficient"); + } - fj_cpu.h_best_objective = +std::numeric_limits::infinity(); - - // nnz count - fj_cpu.cached_mtm_moves.resize(fj_cpu.h_coefficients.size(), - std::make_pair(0, fj_staged_score_t::zero())); + f_t abs_obj_sum = 0; + for (auto var_idx : fj_cpu.h_objective_vars) { + const f_t coeff = fj_cpu.h_obj_coeffs[var_idx]; + abs_obj_sum += coeff < 0 ? -coeff : coeff; + } + fj_cpu.obj_magnitude = abs_obj_sum > 0 ? abs_obj_sum / fj_cpu.h_objective_vars.size() : f_t{1}; + cuopt_assert(isfinite(fj_cpu.obj_magnitude) && fj_cpu.obj_magnitude > 0, + "objective magnitude unit must be finite and positive"); fj_cpu.cached_cstr_bounds.resize(fj_cpu.h_reverse_coefficients.size()); for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { @@ -1408,20 +2602,164 @@ void finalize_fj_cpu_host_initialization( } } - fj_cpu.flip_move_computed.resize(n_variables, false); - fj_cpu.var_bitmap.resize(n_variables, false); - fj_cpu.iter_mtm_vars.reserve(n_variables); + // precompute the binvars-pre-row tables for 2opt + fj_cpu.h_binrow_offsets.resize(n_constraints + 1); + fj_cpu.h_binrow_vars.clear(); + for (i_t cstr_idx = 0; cstr_idx < n_constraints; ++cstr_idx) { + fj_cpu.h_binrow_offsets[cstr_idx] = fj_cpu.h_binrow_vars.size(); + auto [offset_begin, offset_end] = range_for_constraint(fj_cpu, cstr_idx); + for (i_t i = offset_begin; i < offset_end; ++i) { + const i_t var_idx = fj_cpu.h_variables[i]; + if (fj_cpu.h_is_binary_variable[var_idx]) { fj_cpu.h_binrow_vars.push_back(var_idx); } + } + } + fj_cpu.h_binrow_offsets[n_constraints] = fj_cpu.h_binrow_vars.size(); + + // Must precede recompute_lhs, which is what first populates them. + fj_cpu.violated_constraints.resize(n_constraints); + fj_cpu.satisfied_constraints.resize(n_constraints); recompute_lhs(fj_cpu); // Precompute static problem features for regression model precompute_problem_features(fj_cpu); + compute_variable_coloring(fj_cpu); +} + +template +static void finalize_fj_cpu_host_initialization_from_template( + fj_cpu_climber_t& fj_cpu, + const fj_cpu_climber_t& tmpl, + i_t n_variables, + i_t n_constraints, + i_t n_integer_vars, + i_t nnz, + const typename mip_solver_settings_t::tolerances_t& tolerances) +{ + raft::common::nvtx::range scope("finalize_fj_cpu_host_initialization_from_template"); + + cuopt_assert(tmpl.h_lhs.size() == static_cast(n_constraints), "template lhs mismatch"); + cuopt_assert(tmpl.violated_constraints.max_size() == n_constraints, + "template violated set mismatch"); + cuopt_assert(tmpl.satisfied_constraints.max_size() == n_constraints, + "template satisfied set mismatch"); + cuopt_assert(tmpl.cached_cstr_bounds.size() == fj_cpu.h_reverse_coefficients.size(), + "template cached bounds mismatch"); + + cuopt_assert(tmpl.h_binrow_offsets.size() == static_cast(n_constraints + 1), + "template binrow offsets mismatch"); + + fj_cpu.h_objective_vars = tmpl.h_objective_vars; + fj_cpu.cached_cstr_bounds = tmpl.cached_cstr_bounds; + fj_cpu.h_binrow_offsets = tmpl.h_binrow_offsets; + fj_cpu.h_binrow_vars = tmpl.h_binrow_vars; + fj_cpu.obj_magnitude = tmpl.obj_magnitude; + + fj_cpu.h_lhs = tmpl.h_lhs; + fj_cpu.h_lhs_sumcomp = tmpl.h_lhs_sumcomp; + fj_cpu.violated_constraints = tmpl.violated_constraints; + fj_cpu.satisfied_constraints = tmpl.satisfied_constraints; + fj_cpu.total_violations = tmpl.total_violations; + fj_cpu.total_violations_sumcomp = tmpl.total_violations_sumcomp; + fj_cpu.h_incumbent_objective = tmpl.h_incumbent_objective; + fj_cpu.h_objective_sumcomp = tmpl.h_objective_sumcomp; + + // The colouring is structural, so it carries over; the score table is this climber's own. + fj_cpu.h_var_color = tmpl.h_var_color; + fj_cpu.n_colors = tmpl.n_colors; + if (fj_cpu.n_colors > 0) { + fj_cpu.h_var_best_score.assign(n_variables, fj_staged_score_t::invalid()); + fj_cpu.h_var_best_delta.assign(n_variables, f_t{0}); + fj_cpu.h_var_best_stamp.assign(n_variables, 0); + fj_cpu.h_var_best_rowsum.assign(n_variables, 0); + fj_cpu.h_var_bucket_stamp.assign(n_variables, 0); + fj_cpu.batch_size_hist.assign(fj_batch_hist_bins, 0); + fj_cpu.h_color_candidates.assign(fj_cpu.n_colors, {}); + fj_cpu.h_color_epoch.assign(fj_cpu.n_colors, 0); + fj_cpu.var_best_epoch = 1; + } + + fj_cpu.n_binary_vars = tmpl.n_binary_vars; + fj_cpu.n_integer_vars = tmpl.n_integer_vars; + fj_cpu.avg_var_degree = tmpl.avg_var_degree; + fj_cpu.max_var_degree = tmpl.max_var_degree; + fj_cpu.var_degree_cv = tmpl.var_degree_cv; + fj_cpu.avg_cstr_degree = tmpl.avg_cstr_degree; + fj_cpu.max_cstr_degree = tmpl.max_cstr_degree; + fj_cpu.cstr_degree_cv = tmpl.cstr_degree_cv; + fj_cpu.problem_density = tmpl.problem_density; + + wire_fj_cpu_host_views(fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + fj_cpu.view.objective_vars = + raft::device_span(fj_cpu.h_objective_vars.data(), fj_cpu.h_objective_vars.size()); +} + +// Slacks at and above n_structural fold into their row's bounds: a*x + alpha*s = rhs with +// s in [lo, hi] becomes rhs - max(alpha*lo, alpha*hi) <= a*x <= rhs - min(alpha*lo, alpha*hi). +template +static void eliminate_slacks(const lp_problem_t& problem, + i_t n_structural, + csr_matrix_t& csr_A, + std::vector& row_lower, + std::vector& row_upper) +{ + cuopt_assert(csr_A.m == problem.num_rows, "row count mismatch"); + cuopt_assert(csr_A.n == problem.num_cols, "column count mismatch"); + cuopt_assert(n_structural > 0, "no structural columns"); + cuopt_assert(n_structural < problem.num_cols, "no slacks to eliminate"); + cuopt_assert(problem.num_cols - n_structural <= problem.num_rows, "more slacks than rows"); + + row_lower = problem.rhs; + row_upper = problem.rhs; + + std::vector row_has_slack(problem.num_rows, 0); + for (i_t j = n_structural; j < problem.num_cols; ++j) { + cuopt_assert(problem.A.col_length(j) == 1, "slack column is not a singleton"); + + const i_t entry = problem.A.col_start[j]; + const i_t row = problem.A.i[entry]; + const f_t alpha = problem.A.x[entry]; + cuopt_assert(std::abs(alpha) == f_t{1}, "slack coefficient is not +/-1"); + cuopt_assert(!row_has_slack[row], "row has more than one slack"); + row_has_slack[row] = 1; + + const f_t scaled_lower = alpha * problem.lower[j]; + const f_t scaled_upper = alpha * problem.upper[j]; + row_lower[row] = problem.rhs[row] - std::max(scaled_lower, scaled_upper); + row_upper[row] = problem.rhs[row] - std::min(scaled_lower, scaled_upper); + cuopt_assert(std::isfinite(row_lower[row]) || std::isfinite(row_upper[row]), + "eliminated row is free on both sides"); + cuopt_assert(row_lower[row] <= row_upper[row], "eliminated row has crossed bounds"); + } + + i_t out = 0; + for (i_t row = 0; row < csr_A.m; ++row) { + const i_t row_start = csr_A.row_start[row]; + const i_t row_end = csr_A.row_start[row + 1]; + csr_A.row_start[row] = out; + for (i_t p = row_start; p < row_end; ++p) { + if (csr_A.j[p] >= n_structural) { continue; } + csr_A.j[out] = csr_A.j[p]; + csr_A.x[out] = csr_A.x[p]; + ++out; + } + } + cuopt_assert( + out == csr_A.row_start[csr_A.m] - static_cast(problem.num_cols - n_structural), + "slack elimination removed the wrong number of entries"); + + csr_A.row_start[csr_A.m] = out; + csr_A.j.resize(out); + csr_A.x.resize(out); + csr_A.nz_max = out; + csr_A.n = n_structural; } template static std::unique_ptr> init_fj_cpu_from_host_lp( const lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex_solver_settings_t& settings, std::atomic& preemption_flag, @@ -1439,16 +2777,27 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( 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 constraint_lower_bounds; + std::vector constraint_upper_bounds; + i_t n_variables; + if (n_structural > 0 && n_structural < problem.num_cols) { + eliminate_slacks(problem, n_structural, csr_A, constraint_lower_bounds, constraint_upper_bounds); + n_variables = n_structural; + } else { + n_variables = problem.num_cols; + // Standard form: every row is an equality. + constraint_lower_bounds = problem.rhs; + constraint_upper_bounds = problem.rhs; + } + + std::vector coefficients = csr_A.x; + std::vector variables = csr_A.j; + std::vector offsets = csr_A.row_start; std::vector variable_bounds(n_variables); std::vector cpufj_variable_types(n_variables); std::vector is_binary_variable(n_variables, 0); @@ -1505,8 +2854,9 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( 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_obj_coeffs = + std::vector(problem.objective.begin(), problem.objective.begin() + n_variables); + 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); @@ -1534,6 +2884,14 @@ static std::unique_ptr> init_fj_cpu_from_host_lp( template static void sanity_checks(fj_cpu_climber_t& fj_cpu) { + // Assigning any of these wrappers from a plain vector rebinds its buffer and strands the span. + cuopt_assert(fj_cpu.view.incumbent_assignment.data() == fj_cpu.h_assignment.data(), + "incumbent_assignment span no longer covers h_assignment"); + cuopt_assert(fj_cpu.view.incumbent_lhs.data() == fj_cpu.h_lhs.data(), + "incumbent_lhs span no longer covers h_lhs"); + cuopt_assert(fj_cpu.view.pb.variable_bounds.data() == fj_cpu.h_var_bounds.data(), + "variable_bounds span no longer covers h_var_bounds"); + // Check that each variable is within its bounds for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { f_t val = fj_cpu.h_assignment[var_idx]; @@ -1544,7 +2902,7 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each violated constraint is actually violated and not present in // satisfied_constraints for (const auto& cstr_idx : fj_cpu.violated_constraints) { - cuopt_assert(fj_cpu.satisfied_constraints.count(cstr_idx) == 0, + cuopt_assert(!fj_cpu.satisfied_constraints.contains(cstr_idx), "Violated constraint also in satisfied_constraints"); f_t lhs = fj_cpu.h_lhs[cstr_idx]; f_t tol = fj_cpu.view.get_corrected_tolerance(cstr_idx); @@ -1555,7 +2913,7 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each satisfied constraint is actually satisfied and not present in // violated_constraints for (const auto& cstr_idx : fj_cpu.satisfied_constraints) { - cuopt_assert(fj_cpu.violated_constraints.count(cstr_idx) == 0, + cuopt_assert(!fj_cpu.violated_constraints.contains(cstr_idx), "Satisfied constraint also in violated_constraints"); f_t lhs = fj_cpu.h_lhs[cstr_idx]; f_t tol = fj_cpu.view.get_corrected_tolerance(cstr_idx); @@ -1565,8 +2923,8 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) // Check that each constraint is in exactly one of violated_constraints or satisfied_constraints for (i_t cstr_idx = 0; cstr_idx < fj_cpu.view.pb.n_constraints; ++cstr_idx) { - bool in_viol = fj_cpu.violated_constraints.count(cstr_idx) > 0; - bool in_sat = fj_cpu.satisfied_constraints.count(cstr_idx) > 0; + bool in_viol = fj_cpu.violated_constraints.contains(cstr_idx); + bool in_sat = fj_cpu.satisfied_constraints.contains(cstr_idx); cuopt_assert( in_viol != in_sat, "Constraint must be in exactly one of violated_constraints or satisfied_constraints"); @@ -1575,6 +2933,8 @@ static void sanity_checks(fj_cpu_climber_t& fj_cpu) cuopt_assert(fj_cpu.h_cstr_right_weights[cstr_idx] >= 0, "Weights should be positive or zero"); } cuopt_assert(fj_cpu.h_objective_weight >= 0, "Objective weight should be positive or zero"); + cuopt_assert(fj_cpu.seed_objective_weight >= 0, + "Objective weight floor should be positive or zero"); } template @@ -1584,6 +2944,7 @@ std::unique_ptr> fj_t::create_cpu_climber( const std::vector& right_weights, f_t objective_weight, std::atomic& preemption_flag, + const probing_cache_t* probing_cache, fj_settings_t settings, bool randomize_params) { @@ -1592,7 +2953,7 @@ std::unique_ptr> fj_t::create_cpu_climber( auto fj_cpu = std::make_unique>(preemption_flag); // Initialize fj_cpu with all the data - init_fj_cpu(*fj_cpu, solution, left_weights, right_weights, objective_weight); + init_fj_cpu(*fj_cpu, solution, left_weights, right_weights, objective_weight, probing_cache); fj_cpu->settings = settings; if (randomize_params) { auto rng = std::mt19937(cuopt::seed_generator::get_seed()); @@ -1605,18 +2966,474 @@ std::unique_ptr> fj_t::create_cpu_climber( return fj_cpu; // move } +constexpr int32_t fj_nnz_per_refresh_stretch = 100000; +constexpr int32_t fj_max_refresh_stretch = 8; + +// Above this a short LP spends more time moving the matrix than it can pay back as a seed, and the +// wall budget the LP is allowed out of the lane's own. +constexpr int64_t fj_lp_seed_nnz_limit = 8'000'000; +constexpr double fj_lp_pump_max_budget_s = 2.0; +constexpr double fj_lp_pump_budget_share = 0.25; +constexpr int32_t fj_lp_pump_projections = 3; + +// One dual simplex solve of a relaxation on the calling thread. Reports whether the returned point +// is usable: a vertex reached at a limit is dual feasible and still worth rounding. +template +static bool solve_lp_relaxation(const simplex::user_problem_t& relaxation, + double time_limit, + std::vector& x) +{ + simplex::lp_status_t status = simplex::lp_status_t::UNSET; + double seconds = 0; + + // solve_linear_program_advanced, whose status separates a limit -- which leaves a usable vertex + // behind -- from infeasibility. Guarded on f_t because dual simplex is only built for double. + if constexpr (std::is_same_v) { + simplex_solver_settings_t lp_settings; + lp_settings.relaxation = true; + lp_settings.time_limit = time_limit; + lp_settings.log.log = false; + // The portfolio already pins one CPU per lane, and the simplex default is + // omp_get_max_threads() - 1, which would open a second portfolio inside this lane's worker. + lp_settings.num_threads = 1; + + const f_t lp_start = tic(); + lp_problem_t converted(relaxation.handle_ptr, + relaxation.num_rows, + relaxation.num_cols, + relaxation.A.col_start[relaxation.A.n]); + std::vector new_slacks; + simplex::dualize_info_t dualize_info; + simplex::convert_user_problem(relaxation, lp_settings, converted, new_slacks, dualize_info); + + simplex::lp_solution_t lp_solution(converted.num_rows, converted.num_cols); + std::vector vstatus; + std::vector edge_norms; + status = simplex::solve_linear_program_advanced( + converted, lp_start, lp_settings, lp_solution, vstatus, edge_norms); + x = std::move(lp_solution.x); + seconds = toc(lp_start); + } + + const bool usable = status == simplex::lp_status_t::OPTIMAL || + status == simplex::lp_status_t::TIME_LIMIT || + status == simplex::lp_status_t::ITERATION_LIMIT || + status == simplex::lp_status_t::CONCURRENT_LIMIT || + status == simplex::lp_status_t::WORK_LIMIT; + CUOPT_LOG_DEBUG("CPUFJ LP relaxation: %s after %.3fs of %.3fs%s", + simplex::lp_status_to_string(status).c_str(), + seconds, + time_limit, + usable ? "" : ", discarded"); + return usable; +} + +// The L1 distance to a rounded point, as an exact LP. Every integer x gains a d with the pair +// x - d <= r and -x - d <= -r, so minimising sum(d) minimises sum(abs(x - r)). +template +static simplex::user_problem_t make_lp_distance_problem( + const simplex::user_problem_t& base, + fj_cpu_climber_t& fj_cpu, + const std::vector& rounded) +{ + std::vector integer_vars; + for (i_t var = 0; var < fj_cpu.view.pb.n_variables; ++var) + if (is_integer_var(fj_cpu, var)) integer_vars.push_back(var); + const i_t n_distance = (i_t)integer_vars.size(); + + simplex::user_problem_t result(base.handle_ptr); + result.num_rows = base.num_rows + 2 * n_distance; + result.num_cols = base.num_cols + n_distance; + + // The model's own objective is dropped: this LP measures distance alone. + result.objective.assign(result.num_cols, f_t{0}); + for (i_t k = 0; k < n_distance; ++k) result.objective[base.num_cols + k] = f_t{1}; + + result.lower = base.lower; + result.upper = base.upper; + result.lower.resize(result.num_cols, f_t{0}); + result.upper.resize(result.num_cols, std::numeric_limits::infinity()); + + result.rhs = base.rhs; + result.row_sense = base.row_sense; + result.rhs.reserve(result.num_rows); + result.row_sense.reserve(result.num_rows); + for (i_t k = 0; k < n_distance; ++k) { + result.rhs.push_back(rounded[integer_vars[k]]); + result.row_sense.push_back('L'); + result.rhs.push_back(-rounded[integer_vars[k]]); + result.row_sense.push_back('L'); + } + result.range_rows = base.range_rows; + result.range_value = base.range_value; + result.num_range_rows = base.num_range_rows; + + const i_t base_nnz = base.A.col_start[base.A.n]; + csc_matrix_t matrix(result.num_rows, result.num_cols, base_nnz + 4 * n_distance); + i_t out = 0; + i_t next_integer = 0; + for (i_t j = 0; j < base.num_cols; ++j) { + matrix.col_start[j] = out; + for (i_t p = base.A.col_start[j]; p < base.A.col_start[j + 1]; ++p) { + matrix.i[out] = base.A.i[p]; + matrix.x[out++] = base.A.x[p]; + } + if (next_integer < n_distance && integer_vars[next_integer] == j) { + const i_t row = base.num_rows + 2 * next_integer++; + matrix.i[out] = row; + matrix.x[out++] = f_t{1}; + matrix.i[out] = row + 1; + matrix.x[out++] = f_t{-1}; + } + } + for (i_t k = 0; k < n_distance; ++k) { + matrix.col_start[base.num_cols + k] = out; + const i_t row = base.num_rows + 2 * k; + matrix.i[out] = row; + matrix.x[out++] = f_t{-1}; + matrix.i[out] = row + 1; + matrix.x[out++] = f_t{-1}; + } + matrix.col_start[result.num_cols] = out; + cuopt_assert(out == base_nnz + 4 * n_distance, "distance problem nonzero count mismatch"); + result.A = std::move(matrix); + return result; +} + +constexpr int32_t fj_bound_prop_rounds = 10; +// A deduction is committed only when it moves a bound by more than this many absolute tolerances. +constexpr double fj_bound_prop_commit_scale = 1e3; + +// Raises a lower bound to a deduced limit. Returns whether the domain moved. +template +static bool tighten_lower_bound(fj_cpu_climber_t& fj_cpu, + std::vector& lower, + const std::vector& upper, + i_t var, + f_t limit, + f_t commit_threshold) +{ + if (!isfinite(limit)) return false; + if (is_integer_var(fj_cpu, var)) + limit = ceil(limit - fj_cpu.view.pb.tolerances.integrality_tolerance); + if (limit > upper[var]) return false; + if (limit <= lower[var] + commit_threshold) return false; + lower[var] = limit; + return true; +} + +// Lowers an upper bound to a deduced limit. Returns whether the domain moved. +template +static bool tighten_upper_bound(fj_cpu_climber_t& fj_cpu, + const std::vector& lower, + std::vector& upper, + i_t var, + f_t limit, + f_t commit_threshold) +{ + if (!isfinite(limit)) return false; + if (is_integer_var(fj_cpu, var)) + limit = floor(limit + fj_cpu.view.pb.tolerances.integrality_tolerance); + if (limit < lower[var]) return false; + if (limit >= upper[var] - commit_threshold) return false; + upper[var] = limit; + return true; +} + +// Narrows this lane's domains by activity propagation, then reclassifies: an integer squeezed to +// [0,1] becomes eligible for the binary engine. +template +static void apply_bound_propagation(fj_cpu_climber_t& fj_cpu) +{ + if (!fj_cpu.use_bound_prop) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + const i_t n_constraints = fj_cpu.view.pb.n_constraints; + const f_t commit = + (f_t)fj_bound_prop_commit_scale * fj_cpu.view.pb.tolerances.absolute_tolerance; + + std::vector lower(n_variables); + std::vector upper(n_variables); + for (i_t var = 0; var < n_variables; ++var) { + auto bounds = fj_cpu.h_var_bounds[var].get(); + lower[var] = get_lower(bounds); + upper[var] = get_upper(bounds); + } + + bool changed = true; + int32_t pass = 0; + for (; changed && pass < fj_bound_prop_rounds; ++pass) { + changed = false; + for (i_t row = 0; row < n_constraints; ++row) { + const f_t row_lb = fj_cpu.h_cstr_lb[row]; + const f_t row_ub = fj_cpu.h_cstr_ub[row]; + const bool has_lb = isfinite(row_lb); + const bool has_ub = isfinite(row_ub); + if (!has_lb && !has_ub) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + + f_t min_activity = 0; + f_t max_activity = 0; + bool finite_min = true; + bool finite_max = true; + for (i_t p = begin; p < end; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + if (coeff == f_t{0}) continue; + const i_t var = fj_cpu.h_variables[p]; + const f_t min_x = coeff > 0 ? lower[var] : upper[var]; + const f_t max_x = coeff > 0 ? upper[var] : lower[var]; + finite_min &= isfinite(min_x); + finite_max &= isfinite(max_x); + if (finite_min) min_activity += coeff * min_x; + if (finite_max) max_activity += coeff * max_x; + } + + const bool from_row_ub = finite_min && has_ub; + const bool from_row_lb = finite_max && has_lb; + if (!from_row_ub && !from_row_lb) continue; + + // The activities are not refreshed as the loop below narrows the row's own variables, and a + // stale bound is the looser one, so a deduction taken against it is the weaker one. + for (i_t p = begin; p < end; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + if (coeff == f_t{0}) continue; + const i_t var = fj_cpu.h_variables[p]; + + if (from_row_ub) { + const f_t rest = min_activity - coeff * (coeff > 0 ? lower[var] : upper[var]); + const f_t limit = (row_ub - rest) / coeff; + changed |= coeff > 0 ? tighten_upper_bound(fj_cpu, lower, upper, var, limit, commit) + : tighten_lower_bound(fj_cpu, lower, upper, var, limit, commit); + } + if (from_row_lb) { + const f_t rest = max_activity - coeff * (coeff > 0 ? upper[var] : lower[var]); + const f_t limit = (row_lb - rest) / coeff; + changed |= coeff > 0 ? tighten_lower_bound(fj_cpu, lower, upper, var, limit, commit) + : tighten_upper_bound(fj_cpu, lower, upper, var, limit, commit); + } + } + } + } + + fj_cpu.h_binary_indices.clear(); + fj_cpu.n_binary_vars = 0; + fj_cpu.n_integer_vars = 0; + i_t tightened = 0; + bool clamped = false; + for (i_t var = 0; var < n_variables; ++var) { + auto bounds = fj_cpu.h_var_bounds[var].get(); + cuopt_assert(!(lower[var] < get_lower(bounds)), "propagation widened a lower bound"); + cuopt_assert(!(upper[var] > get_upper(bounds)), "propagation widened an upper bound"); + cuopt_assert(!(lower[var] > upper[var]), "propagation emptied a domain"); + const bool moved = lower[var] != get_lower(bounds) || upper[var] != get_upper(bounds); + + // Same rule as problem_t::compute_binary_var_table, fixed binaries included: a domain narrowed + // to a point is no longer binary. + const bool integer = is_integer_var(fj_cpu, var); + const bool binary = integer && fj_cpu.view.pb.integer_equal(lower[var], (f_t)0) && + fj_cpu.view.pb.integer_equal(upper[var], (f_t)1); + fj_cpu.h_is_binary_variable[var] = binary; + if (binary) { + fj_cpu.h_binary_indices.push_back(var); + ++fj_cpu.n_binary_vars; + } else if (integer) { + ++fj_cpu.n_integer_vars; + } + if (!moved) continue; + + ++tightened; + fj_cpu.h_var_bounds[var] = typename type_2::type{lower[var], upper[var]}; + + const f_t value = fj_cpu.h_assignment[var]; + const f_t clamped_value = std::clamp(value, lower[var], upper[var]); + if (clamped_value != value) { + cuopt_assert(!integer || fj_cpu.view.pb.is_integer(clamped_value), + "bound clamp broke integrality"); + fj_cpu.h_assignment[var] = clamped_value; + clamped = true; + } + fj_cpu.h_best_assignment[var] = + std::clamp((f_t)fj_cpu.h_best_assignment[var], lower[var], upper[var]); + } + + // h_binary_indices reallocated, so the span over it would otherwise dangle. + fj_cpu.view.pb.binary_indices = + raft::device_span(fj_cpu.h_binary_indices.data(), fj_cpu.h_binary_indices.size()); + + if (clamped) recompute_lhs(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "bound prop")); + + CUOPT_LOG_DEBUG("%sCPUFJ bound prop: %d passes, %d domains tightened, %d binary of %d integer", + fj_cpu.log_prefix.c_str(), + pass, + tightened, + fj_cpu.n_binary_vars, + fj_cpu.n_binary_vars + fj_cpu.n_integer_vars); +} + +// A bounded feasibility pump for the LP lane, run on the lane's own thread. An integral-feasible +// projection is published; otherwise FJ starts from the least violated rounding the pump saw. +template +static void apply_lp_rounded_seed(fj_cpu_climber_t& fj_cpu, f_t lane_time_limit) +{ + if (!fj_cpu.use_lp_seed || fj_cpu.pb_ptr == nullptr) return; + if (fj_cpu.view.pb.nnz > fj_lp_seed_nnz_limit) return; + + const double budget = + std::min(fj_lp_pump_max_budget_s, fj_lp_pump_budget_share * (double)lane_time_limit); + if (budget <= 0) return; + + simplex::user_problem_t base(fj_cpu.pb_ptr->handle_ptr); + fj_cpu.pb_ptr->get_host_user_problem(base); + + const auto started = std::chrono::steady_clock::now(); + const i_t n_variables = fj_cpu.view.pb.n_variables; + + std::vector rounded; + std::vector selected; + f_t selected_violation = -std::numeric_limits::infinity(); + + for (int32_t projection = 0; projection < fj_lp_pump_projections; ++projection) { + const double remaining = + budget - std::chrono::duration(std::chrono::steady_clock::now() - started).count(); + if (remaining <= 0) break; + + // Projection 0 is the plain relaxation; the rest chase the previous rounding. + const auto distance = projection == 0 ? simplex::user_problem_t(base.handle_ptr) + : make_lp_distance_problem(base, fj_cpu, rounded); + const auto& relaxation = projection == 0 ? base : distance; + + std::vector x; + if (!solve_lp_relaxation(relaxation, remaining, x)) break; + // convert_user_problem appends slacks, so the model's own variables are the leading columns. + if ((i_t)x.size() < n_variables) break; + + rounded.resize(n_variables); + cuopt::pcgenerator_t rng(fj_cpu.settings.seed); + bool valid = true; + for (i_t var = 0; var < n_variables && valid; ++var) { + const auto bounds = fj_cpu.h_var_bounds[var].get(); + const f_t lower = get_lower(bounds); + const f_t upper = get_upper(bounds); + f_t value = std::clamp(x[var], lower, upper); + if (!isfinite(value)) { + valid = false; + break; + } + if (is_integer_var(fj_cpu, var)) { + // Rounded up with probability equal to the fractional part, so successive projections of + // the same point explore different corners. + const f_t fraction = value - floor(value); + value = rng.next_double() < fraction ? ceil(value) : floor(value); + // A variable with no integral value inside its bounds cannot be seeded at all without + // breaking the engine's integrality invariant. + valid = value >= lower && value <= upper; + } + rounded[var] = value; + } + if (!valid) break; + + // Copied in place: assigning the wrapper from a plain vector rebinds its buffer and leaves the + // incumbent_assignment span on freed memory. + std::copy(rounded.begin(), rounded.end(), fj_cpu.h_assignment.begin()); + recompute_lhs(fj_cpu); + // total_violations sums a non-positive excess, so the greater value is the closer point. + if (fj_cpu.total_violations > selected_violation) { + selected_violation = fj_cpu.total_violations; + selected = rounded; + } + + // The rounded point can already be integral-feasible. It never passed through apply_move, so + // the incumbent is recorded here through the same contract that path uses. + if (fj_cpu.violated_constraints.empty() && check_variable_feasibility(fj_cpu)) { + std::copy(rounded.begin(), rounded.end(), fj_cpu.h_best_assignment.begin()); + fj_cpu.h_best_objective = + fj_cpu.h_incumbent_objective - fj_cpu.settings.parameters.breakthrough_move_epsilon; + fj_cpu.feasible_found = true; + CUOPT_LOG_DEBUG("%sCPUFJ new incumbent: objective %.17g", + fj_cpu.log_prefix.c_str(), + fj_cpu.h_best_objective); + if (fj_cpu.improvement_callback) { + fj_cpu.improvement_callback(fj_cpu.h_incumbent_objective, + fj_cpu.h_assignment, + fj_cpu.work_units_elapsed.load(std::memory_order_acquire)); + } + if (fj_cpu.shared_incumbent) { + fj_cpu.shared_incumbent->publish(fj_cpu.h_incumbent_objective, fj_cpu.h_assignment); + } + return; + } + } + + if (selected.empty()) return; + std::copy(selected.begin(), selected.end(), fj_cpu.h_assignment.begin()); + std::copy(selected.begin(), selected.end(), fj_cpu.h_best_assignment.begin()); + recompute_lhs(fj_cpu); + cuopt_func_call(audit_assignment_bounds(fj_cpu, "lp pump")); +} + template void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double work_unit_limit) { - i_t local_mins = 0; - auto loop_start = std::chrono::high_resolution_clock::now(); + const auto solve_start = std::chrono::high_resolution_clock::now(); + // Precedes the dispatch below because a variable it squeezes to [0,1] can bring the whole model + // into the binary engine's shape. + apply_bound_propagation(*fj_cpu); + // Also ahead of the dispatch, so an all-binary model gets the same LP-derived start. + apply_lp_rounded_seed(*fj_cpu, in_time_limit); + + const bool paid_setup = fj_cpu->use_bound_prop || fj_cpu->use_lp_seed; + const f_t setup_seconds = + paid_setup + ? std::chrono::duration(std::chrono::high_resolution_clock::now() - solve_start).count() + : f_t{0}; + const f_t remaining = std::max(f_t{0}, in_time_limit - setup_seconds); + if (remaining <= f_t{0}) return; + + // problem fits the binary fastpath shape? run it (engine is solve-local) + if (try_cpufj_binary_solve(*fj_cpu, remaining, work_unit_limit)) return; + + i_t local_mins = 0; + std::vector batch_moves; + // The LP comes out of this lane's own budget; every other lane's clock starts where it did. + auto loop_start = (fj_cpu->use_lp_seed || fj_cpu->use_bound_prop) + ? solve_start + : std::chrono::high_resolution_clock::now(); auto time_limit = std::chrono::milliseconds(static_cast(std::floor(in_time_limit * 1000.0))); - auto loop_time_start = std::chrono::high_resolution_clock::now(); + auto loop_time_start = loop_start; + + fj_cpu->rng.seed(fj_cpu->settings.seed); // Initialize feature tracking fj_cpu->last_feature_log_time = loop_start; fj_cpu->prev_best_objective = fj_cpu->h_best_objective; fj_cpu->iterations_since_best = 0; + reset_infeasible_checkpoint(*fj_cpu); + fj_cpu->n_checkpoint_restores = 0; + fj_cpu->n_checkpoint_snapshots = 0; + fj_cpu->restores_since_improvement = 0; + fj_cpu->max_restores_since_improvement = 0; + + // The recompute is O(nnz), so a fixed period costs a growing share of the budget. + cuopt_assert(fj_cpu->settings.parameters.lhs_refresh_period > 0, + "lhs_refresh_period should be positive"); + const i_t nnz_stretch = std::min( + (i_t)fj_cpu->h_coefficients.size() / fj_nnz_per_refresh_stretch, fj_max_refresh_stretch); + const i_t refresh_period = fj_cpu->settings.parameters.lhs_refresh_period * (1 + nnz_stretch); + //const i_t refresh_period = 5000 * (1 + nnz_stretch); + cuopt_assert(refresh_period > 0, "refresh period overflowed"); + fj_cpu->lhs_refresh_period_used = refresh_period; + + // Whatever the seed left behind, these rows are satisfiable on their own, so the walk should not + // start with them in the violated set competing for the sampler's attention. + for (i_t var : fj_cpu->epigraph_vars) { + const f_t delta = project_epigraph_variable(*fj_cpu, var) - (f_t)fj_cpu->h_assignment[var]; + if (delta == f_t{0}) continue; + apply_move(*fj_cpu, var, delta, false); + ++fj_cpu->n_epigraph_projections; + } while (!fj_cpu->halted && !fj_cpu->preemption_flag.load()) { // Check if 5 seconds have passed @@ -1639,12 +3456,13 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w // periodically recompute the LHS and violation scores // to correct any accumulated numerical errors - cuopt_assert(fj_cpu->settings.parameters.lhs_refresh_period > 0, - "lhs_refresh_period should be positive"); - if (fj_cpu->iterations % fj_cpu->settings.parameters.lhs_refresh_period == 0 || - fj_cpu->trigger_early_lhs_recomputation) { + if (fj_cpu->trigger_early_lhs_recomputation) { + ++fj_cpu->n_lhs_recompute_bigval; recompute_lhs(*fj_cpu); fj_cpu->trigger_early_lhs_recomputation = false; + } else if (fj_cpu->iterations % refresh_period == 0) { + ++fj_cpu->n_lhs_recompute_periodic; + recompute_lhs(*fj_cpu); } fj_move_t move = fj_move_t{-1, 0}; @@ -1654,9 +3472,23 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w bool is_mtm_sat = false; // Perform lift moves + fj_move_t lift_companion = fj_move_t{-1, 0}; if (fj_cpu->violated_constraints.empty()) { thrust::tie(move, score) = find_lift_move(*fj_cpu); - if (score > fj_staged_score_t::zero()) is_lift = true; + if (score > fj_staged_score_t::zero()) { + is_lift = true; + } else { + // Pairs are only reachable once no single improving flip preserves feasibility. + fj_move_t first, second; + fj_staged_score_t pair_score; + thrust::tie(first, second, pair_score) = find_lift_2opt_move(*fj_cpu); + if (pair_score > fj_staged_score_t::zero()) { + move = first; + lift_companion = second; + score = pair_score; + is_lift = true; + } + } } // Regular MTM if (!(score > fj_staged_score_t::zero())) { @@ -1668,17 +3500,40 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w thrust::tie(move, score) = find_mtm_move_sat(*fj_cpu, fj_cpu->mtm_sat_samples); if (score > fj_staged_score_t::zero()) is_mtm_sat = true; } + // The scorers target one row at a time, so on an epigraph variable they climb toward the bound + // its rows already imply. The projection lands there in one move at the same O(degree) cost. + if (move.var_idx >= 0 && fj_cpu->epigraph_push[move.var_idx] != 0) { + const f_t projected = project_epigraph_variable(*fj_cpu, move.var_idx) - + (f_t)fj_cpu->h_assignment[move.var_idx]; + if (projected != f_t{0}) { + move.value = projected; + ++fj_cpu->n_epigraph_projections; + } + } + // if we're in the feasible region but haven't found improvements in the last n iterations, // perturb bool should_perturb = false; if (fj_cpu->violated_constraints.empty() && - fj_cpu->iterations - fj_cpu->last_feasible_entrance_iter > fj_cpu->perturb_interval) { - should_perturb = true; - fj_cpu->last_feasible_entrance_iter = fj_cpu->iterations; + fj_cpu->iterations_since_best > fj_cpu->perturb_interval) { + should_perturb = true; + // Without this the counter stays above the interval and every later iteration perturbs. + fj_cpu->iterations_since_best = 0; } if (score > fj_staged_score_t::zero() && !should_perturb) { + // A 2-opt lift already commits two coupled moves, and its second half is scored against the + // state before both, so it stays on its own. + if (lift_companion.var_idx < 0) { + collect_move_batch(*fj_cpu, move, batch_moves); + for (const auto& batched : batch_moves) + apply_move(*fj_cpu, batched.var_idx, batched.value, false); + } apply_move(*fj_cpu, move.var_idx, move.value, false); + if (lift_companion.var_idx >= 0) { + apply_move(*fj_cpu, lift_companion.var_idx, lift_companion.value, false); + fj_cpu->n_lift_moves_window++; + } // Track move types if (is_lift) fj_cpu->n_lift_moves_window++; if (is_mtm_viol) fj_cpu->n_mtm_viol_moves_window++; @@ -1686,26 +3541,29 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w } else { // Local Min update_weights(*fj_cpu); + track_infeasible_checkpoint(*fj_cpu); if (should_perturb) { perturb(*fj_cpu); - for (size_t i = 0; i < fj_cpu->cached_mtm_moves.size(); i++) - fj_cpu->cached_mtm_moves[i].first = 0; + invalidate_mtm_cache(*fj_cpu); + } + + two_opt_move_t two_opt_move; + if (!should_perturb) two_opt_move = find_two_opt_move(*fj_cpu); + if (two_opt_move.score > fj_staged_score_t::zero()) { + apply_move(*fj_cpu, two_opt_move.first.var_idx, two_opt_move.first.value, true); + apply_move(*fj_cpu, two_opt_move.second.var_idx, two_opt_move.second.value, true); + fj_cpu->n_mtm_viol_moves_window += 2; + } else { + thrust::tie(move, score) = + find_mtm_move_viol(*fj_cpu, 1, true); // pick a single random violated constraint + i_t var_idx = move.var_idx >= 0 ? move.var_idx : 0; + f_t delta = move.var_idx >= 0 ? move.value : 0; + apply_move(*fj_cpu, var_idx, delta, true); } - thrust::tie(move, score) = - find_mtm_move_viol(*fj_cpu, 1, true); // pick a single random violated constraint - i_t var_idx = move.var_idx >= 0 ? move.var_idx : 0; - f_t delta = move.var_idx >= 0 ? move.value : 0; - apply_move(*fj_cpu, var_idx, delta, true); ++local_mins; ++fj_cpu->n_local_minima_window; } - // number of violated constraints is usually small (<100). recomputing from all LHSs is cheap - // and more numerically precise than just adding to the accumulator in apply_move - fj_cpu->total_violations = 0; - for (auto cstr_idx : fj_cpu->violated_constraints) { - fj_cpu->total_violations += fj_cpu->view.excess_score(cstr_idx, fj_cpu->h_lhs[cstr_idx]); - } if (fj_cpu->iterations % fj_cpu->log_interval == 0) { CUOPT_LOG_DEBUG( "%sCPUFJ iteration: %d/%d, local mins: %d, best_objective: %g, viol: %zu, obj weight %g, " @@ -1764,14 +3622,196 @@ void cpufj_solve(fj_cpu_climber_t* fj_cpu, f_t in_time_limit, double w CUOPT_LOG_TRACE("%sCPUFJ Average time per iteration: %.8fms", fj_cpu->log_prefix.c_str(), avg_time_per_iter * 1000.0); + CUOPT_LOG_DEBUG("%sCPUFJ checkpoint: %lld restores, %lld snapshots, max streak %d", + fj_cpu->log_prefix.c_str(), + (long long)fj_cpu->n_checkpoint_restores, + (long long)fj_cpu->n_checkpoint_snapshots, + fj_cpu->max_restores_since_improvement); + log_batch_distribution(*fj_cpu); #if CPUFJ_TIMING_TRACE // Print final timing statistics - CUOPT_LOG_TRACE("=== Final Timing Statistics ==="); + CUOPT_LOG_DEBUG("=== Final Timing Statistics ==="); print_timing_stats(*fj_cpu); #endif } +template +static std::vector copy_to_host_async(const rmm::device_uvector& input, + rmm::cuda_stream_view stream) +{ + std::vector output(input.size()); + raft::copy(output.data(), input.data(), input.size(), stream); + return output; +} + +template +std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings) +{ + using f_t2 = typename type_2::type; + + raft::common::nvtx::range scope("init_fj_cpu_from_optimization_problem"); + + const i_t n_variables = problem.get_n_variables(); + const i_t n_constraints = problem.get_n_constraints(); + const i_t nnz = problem.get_nnz(); + auto stream = problem.get_handle_ptr()->get_stream(); + + auto coefficients = copy_to_host_async(problem.get_constraint_matrix_values(), stream); + auto variables = copy_to_host_async(problem.get_constraint_matrix_indices(), stream); + auto offsets = copy_to_host_async(problem.get_constraint_matrix_offsets(), stream); + auto objective_coefficients = copy_to_host_async(problem.get_objective_coefficients(), stream); + auto variable_lower_bounds = copy_to_host_async(problem.get_variable_lower_bounds(), stream); + auto variable_upper_bounds = copy_to_host_async(problem.get_variable_upper_bounds(), stream); + auto constraint_lower_bounds = copy_to_host_async(problem.get_constraint_lower_bounds(), stream); + auto constraint_upper_bounds = copy_to_host_async(problem.get_constraint_upper_bounds(), stream); + auto constraint_bounds = copy_to_host_async(problem.get_constraint_bounds(), stream); + auto row_types = copy_to_host_async(problem.get_row_types(), stream); + auto variable_types = copy_to_host_async(problem.get_variable_types(), stream); + problem.get_handle_ptr()->sync_stream(); + + cuopt_assert(coefficients.size() == (size_t)nnz, "coefficient size mismatch"); + cuopt_assert(variables.size() == (size_t)nnz, "variable index size mismatch"); + cuopt_assert(offsets.size() == (size_t)(n_constraints + 1), + "constraint offset size mismatch"); + cuopt_assert(!offsets.empty() && offsets.front() == 0, "invalid first constraint offset"); + cuopt_assert(offsets.back() == nnz, "invalid final constraint offset"); + cuopt_assert(std::is_sorted(offsets.begin(), offsets.end()), "unsorted constraint offsets"); + cuopt_assert( + std::all_of(variables.begin(), + variables.end(), + [n_variables](i_t variable) { return variable >= 0 && variable < n_variables; }), + "variable index out of range"); + cuopt_assert(objective_coefficients.size() == (size_t)n_variables, + "objective size mismatch"); + cuopt_assert(variable_lower_bounds.empty() || + variable_lower_bounds.size() == (size_t)n_variables, + "variable lower bound size mismatch"); + cuopt_assert(variable_upper_bounds.empty() || + variable_upper_bounds.size() == (size_t)n_variables, + "variable upper bound size mismatch"); + + if (constraint_lower_bounds.empty() && constraint_upper_bounds.empty()) { + cuopt_assert(row_types.size() == (size_t)n_constraints, "row type size mismatch"); + cuopt_assert(constraint_bounds.size() == (size_t)n_constraints, + "constraint bound size mismatch"); + constraint_lower_bounds.resize(n_constraints); + constraint_upper_bounds.resize(n_constraints); + for (i_t row = 0; row < n_constraints; ++row) { + const f_t bound = constraint_bounds[row]; + if (row_types[row] == 'E') { + constraint_lower_bounds[row] = bound; + constraint_upper_bounds[row] = bound; + } else if (row_types[row] == 'G') { + constraint_lower_bounds[row] = bound; + constraint_upper_bounds[row] = std::numeric_limits::infinity(); + } else { + cuopt_assert(row_types[row] == 'L', "invalid row type"); + constraint_lower_bounds[row] = -std::numeric_limits::infinity(); + constraint_upper_bounds[row] = bound; + } + } + } else { + cuopt_assert(constraint_lower_bounds.size() == (size_t)n_constraints, + "constraint lower bound size mismatch"); + cuopt_assert(constraint_upper_bounds.size() == (size_t)n_constraints, + "constraint upper bound size mismatch"); + } + + if (variable_lower_bounds.empty()) { variable_lower_bounds.assign(n_variables, f_t{0}); } + if (variable_upper_bounds.empty()) { + variable_upper_bounds.assign(n_variables, std::numeric_limits::infinity()); + } + if (variable_types.empty()) { variable_types.assign(n_variables, var_t::CONTINUOUS); } + cuopt_assert(variable_types.size() == (size_t)n_variables, + "variable type size mismatch"); + + if (problem.get_sense()) { + std::transform(objective_coefficients.begin(), + objective_coefficients.end(), + objective_coefficients.begin(), + std::negate{}); + } + + std::vector variable_bounds(n_variables); + std::vector is_binary_variable(n_variables, 0); + std::vector binary_indices; + binary_indices.reserve(n_variables); + i_t n_integer_vars = 0; + for (i_t variable = 0; variable < n_variables; ++variable) { + f_t lower = variable_lower_bounds[variable]; + f_t upper = variable_upper_bounds[variable]; + const bool is_integer = variable_types[variable] == var_t::INTEGER; + if (is_integer) { + lower = std::ceil(lower); + upper = std::floor(upper); + ++n_integer_vars; + } + cuopt_assert(lower <= upper, "crossing variable bounds"); + variable_bounds[variable] = f_t2{lower, upper}; + if (is_integer && lower == f_t{0} && upper == f_t{1}) { + is_binary_variable[variable] = 1; + binary_indices.push_back(variable); + } + } + + csr_matrix_t csr(n_constraints, n_variables, nnz); + csr.x = coefficients; + csr.j = variables; + csr.row_start = offsets; + csc_matrix_t csc(n_constraints, n_variables, nnz); + csr.to_compressed_col(csc); + + std::vector assignment(n_variables, f_t{0}); + for (i_t variable = 0; variable < n_variables; ++variable) { + f_t value = std::clamp( + f_t{0}, get_lower(variable_bounds[variable]), get_upper(variable_bounds[variable])); + if (variable_types[variable] == var_t::INTEGER) { value = std::round(value); } + assignment[variable] = value; + } + + 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(csc.x); + fj_cpu->h_reverse_constraints = std::move(csc.i); + fj_cpu->h_reverse_offsets = std::move(csc.col_start); + 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 = std::move(objective_coefficients); + 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(variable_types); + fj_cpu->h_is_binary_variable = std::move(is_binary_variable); + fj_cpu->h_binary_indices = std::move(binary_indices); + fj_cpu->h_cstr_left_weights.resize(n_constraints, f_t{1}); + fj_cpu->h_cstr_right_weights.resize(n_constraints, f_t{1}); + fj_cpu->max_weight = f_t{1}; + fj_cpu->h_objective_weight = f_t{0}; + fj_cpu->h_assignment = assignment; + fj_cpu->h_best_assignment = std::move(assignment); + fj_cpu->h_lhs.resize(n_constraints); + fj_cpu->h_lhs_sumcomp.resize(n_constraints, f_t{0}); + fj_cpu->h_tabu_nodec_until.resize(n_variables, 0); + fj_cpu->h_tabu_noinc_until.resize(n_variables, 0); + fj_cpu->h_tabu_lastdec.resize(n_variables, 0); + fj_cpu->h_tabu_lastinc.resize(n_variables, 0); + fj_cpu->iterations = 0; + fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + + finalize_fj_cpu_host_initialization( + *fj_cpu, n_variables, n_constraints, n_integer_vars, nnz, tolerances); + return fj_cpu; +} + template std::unique_ptr> init_fj_cpu_standalone( problem_t& problem, @@ -1784,9 +3824,30 @@ std::unique_ptr> init_fj_cpu_standalone( auto fj_cpu = std::make_unique>(preemption_flag); std::vector default_weights(problem.n_constraints, 1.0); - init_fj_cpu(*fj_cpu, solution, default_weights, default_weights, 0.0); - fj_cpu->settings = settings; - fj_cpu->settings.seed = cuopt::seed_generator::get_seed(); + // Early CPUFJ runs while presolve is still probing, so there are no implications to hand it + const probing_cache_t* no_implications = nullptr; + init_fj_cpu(*fj_cpu, solution, default_weights, default_weights, 0.0, no_implications); + // settings.seed is caller-drawn: seed_generator steps a non-atomic global and this may run + // concurrently across lanes. + fj_cpu->settings = settings; + + return fj_cpu; +} + +template +std::unique_ptr> init_fj_cpu_clone( + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings) +{ + raft::common::nvtx::range scope("init_fj_cpu_clone"); + + auto fj_cpu = std::make_unique>(preemption_flag); + + std::vector default_weights(tmpl.view.pb.n_constraints, 1.0); + init_fj_cpu_from_template(*fj_cpu, tmpl, default_weights, default_weights, f_t{0}); + // See init_fj_cpu_standalone: the seed is caller-drawn, not taken from the global generator. + fj_cpu->settings = settings; return fj_cpu; } @@ -1797,54 +3858,81 @@ void fj_cpu_worker_t::fj_cpu_deleter_t::operator()(fj_cpu_climber_t +std::shared_ptr> make_fj_cpu_shared_incumbent() +{ + return std::make_shared>(); +} + template void fj_cpu_worker_t::create_worker( const lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex_solver_settings_t& settings, std::string log_prefix, - int64_t seed) + int64_t seed, + int lane) { auto new_climber = init_fj_cpu_from_host_lp( - problem, variable_types, seed_assignment, settings, preemption_flag, seed); + problem, variable_types, n_structural, seed_assignment, settings, preemption_flag, seed); fj_cpu.reset(new_climber.release()); fj_cpu->log_prefix = std::move(log_prefix); fj_cpu->improvement_callback = improvement_callback; + fj_cpu->shared_incumbent = shared_incumbent; + fj_cpu->halted = false; + preemption_flag = false; + is_initialized = true; + if (lane >= 0) { apply_lane_diversification(*fj_cpu, lane, fj_cpu->settings.seed); } } template void fj_cpu_worker_t::run_async(f_t time_limit, double work_unit_limit) { - if (!fj_cpu) return; + if (!is_initialized) return; -#pragma omp task shared(fj_cpu) firstprivate(time_limit, work_unit_limit) \ - priority(CUOPT_DEFAULT_TASK_PRIORITY) default(none) depend(out : *fj_cpu) - cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); + auto& fj_ptr = fj_cpu; +#pragma omp task shared(fj_cpu, is_initialized, fj_ptr) firstprivate(time_limit, work_unit_limit) \ + priority(CUOPT_DEFAULT_TASK_PRIORITY) default(none) depend(out : fj_ptr) + { + if (is_initialized) { cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); } + } } template void fj_cpu_worker_t::run_sync(f_t time_limit, double work_unit_limit) { - if (!fj_cpu) return; + if (!is_initialized) return; cpufj_solve(fj_cpu.get(), time_limit, work_unit_limit); + is_initialized = false; fj_cpu.reset(); } template void fj_cpu_worker_t::stop() { - if (!fj_cpu) return; + if (!is_initialized) return; + + preemption_flag = true; - fj_cpu->preemption_flag = true; - fj_cpu->halted = true; -#pragma omp taskwait depend(in : *fj_cpu) + auto& fj_ptr = fj_cpu; +#pragma omp taskwait depend(in : fj_ptr) + is_initialized = false; fj_cpu.reset(); } +template +void fj_cpu_worker_t::send_stop_signal() +{ + preemption_flag = true; +} + #if MIP_INSTANTIATE_FLOAT template class fj_t; template struct fj_cpu_worker_t; +template std::shared_ptr> +make_fj_cpu_shared_incumbent(); template void cpufj_solve(fj_cpu_climber_t* fj_cpu, float in_time_limit, double work_unit_limit); @@ -1853,6 +3941,15 @@ 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_clone( + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + 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, @@ -1865,6 +3962,8 @@ template void finalize_fj_cpu_host_initialization( #if MIP_INSTANTIATE_DOUBLE template class fj_t; template struct fj_cpu_worker_t; +template std::shared_ptr> +make_fj_cpu_shared_incumbent(); template void cpufj_solve(fj_cpu_climber_t* fj_cpu, double in_time_limit, double work_unit_limit); @@ -1873,6 +3972,15 @@ 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_clone( + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings); +template std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + 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, @@ -1882,4 +3990,820 @@ template void finalize_fj_cpu_host_initialization( const typename mip_solver_settings_t::tolerances_t& tolerances); #endif +// Above this the O(nnz) seed passes eat a meaningful slice of a short budget, so they are skipped. +constexpr int64_t fj_seed_nnz_limit = 8'000'000; + +// Budget for the matching seed and the widest exact-one row it will take into the graph. +constexpr double fj_matching_budget_s = 0.45; +constexpr int32_t fj_matching_max_row_width = 20000; + +// The aggressive corner pushes harder than the covering seed: more passes, a longer budget, a +// tighter clock, and it gives up as soon as a pass changes nothing. +constexpr int32_t fj_aggressive_passes = 6; +constexpr double fj_aggressive_budget_s = 0.9; + +// Cardinality-row detection: coefficient agreement tolerance and the widest row worth peeling. +constexpr double fj_exact_k_tol = 1e-6; +constexpr int32_t fj_exact_k_max_width = 20000; +constexpr double fj_exact_k_budget_s = 0.5; +// The anchor repair only runs when this fraction of the rows is violated, and gets this long. +constexpr int32_t fj_anchor_repair_violated_share = 5; +constexpr double fj_anchor_repair_budget_s = 0.1; + +// Jumps each two-sided variable to whichever bound has fewer rows locking it in that direction. +template +static void apply_lock_weighted_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { + const f_t lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()); + if (!isfinite(lb) || !isfinite(ub) || lb >= ub) continue; + + i_t lock_up = 0; + i_t lock_down = 0; + const auto range = reverse_range_for_var(fj_cpu, var_idx); + for (i_t i = range.first; i < range.second; ++i) { + const f_t coeff = fj_cpu.h_reverse_coefficients[i]; + const i_t cstr_idx = fj_cpu.h_reverse_constraints[i]; + const bool has_lb = isfinite((f_t)fj_cpu.h_cstr_lb[cstr_idx]); + const bool has_ub = isfinite((f_t)fj_cpu.h_cstr_ub[cstr_idx]); + if (coeff > 0) { + lock_up += has_ub; + lock_down += has_lb; + } else if (coeff < 0) { + lock_up += has_lb; + lock_down += has_ub; + } + } + + f_t new_val = lock_up <= lock_down ? ub : lb; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Jumps each bounded objective variable to the bound that minimises its own objective term. +template +static void apply_objective_corner_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const i_t n_variables = fj_cpu.view.pb.n_variables; + for (i_t var_idx = 0; var_idx < n_variables; ++var_idx) { + const f_t coeff = fj_cpu.h_obj_coeffs[var_idx]; + if (coeff == 0) continue; + + const f_t lb = get_lower(fj_cpu.h_var_bounds[var_idx].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var_idx].get()); + if (!isfinite(lb) || !isfinite(ub) || lb >= ub) continue; + + f_t new_val = coeff > 0 ? lb : ub; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// A single-variable integer step on a row, with the magnitude of its effect on the row sum. +template +struct row_repair_move_t { + f_t effect; + i_t var; + f_t coeff; + f_t new_val; +}; + +// Collects the unit integer steps that push this row's sum in `direction`, largest effect first. +template +static void collect_row_repair_moves(fj_cpu_climber_t& fj_cpu, + i_t row_begin, + i_t row_end, + f_t direction, + f_t tol, + std::vector>& out) +{ + out.clear(); + for (i_t i = row_begin; i < row_end; ++i) { + const i_t var = fj_cpu.h_variables[i]; + if (!is_integer_var(fj_cpu, var)) continue; + + const f_t coeff = fj_cpu.h_coefficients[i]; + const f_t val = fj_cpu.h_assignment[var]; + const f_t lb = get_lower(fj_cpu.h_var_bounds[var].get()); + const f_t ub = get_upper(fj_cpu.h_var_bounds[var].get()); + const bool is_bin = fj_cpu.h_is_binary_variable[var] != 0; + + // Raising the variable shifts the sum by `direction * coeff`; lowering it by the negation. + const f_t raise = direction * coeff; + if (raise > 0 && val < ub - tol) { + const f_t new_val = is_bin ? (f_t)1 : std::floor(val) + 1; + if (new_val > val && new_val <= ub + tol) out.push_back({raise, var, coeff, new_val}); + } else if (raise < 0 && val > lb + tol) { + const f_t new_val = is_bin ? (f_t)0 : std::ceil(val) - 1; + if (new_val < val && new_val >= lb - tol) out.push_back({-raise, var, coeff, new_val}); + } + } + std::sort(out.begin(), out.end(), [](const row_repair_move_t& a, + const row_repair_move_t& b) { + return a.effect > b.effect; + }); +} + +// Time-boxed greedy row repair. Deliberately myopic, so it reverts unless it strictly reduces the +// violated-row count against the incoming anchor. +template +static void apply_greedy_covering_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + recompute_lhs(fj_cpu); + const i_t baseline_violated = fj_cpu.violated_constraints.size(); + const auto anchor_assignment = fj_cpu.h_assignment; + + const i_t n_constraints = fj_cpu.view.pb.n_constraints; + std::vector row_order(n_constraints); + for (i_t i = 0; i < n_constraints; ++i) + row_order[i] = i; + std::sort(row_order.begin(), row_order.end(), [&](i_t a, i_t b) { + return (fj_cpu.h_offsets[a + 1] - fj_cpu.h_offsets[a]) < + (fj_cpu.h_offsets[b + 1] - fj_cpu.h_offsets[b]); + }); + + const auto started = std::chrono::steady_clock::now(); + const double time_budget_s = 0.4; + const f_t tol = 1e-6; + const i_t max_passes = 2; + std::vector> candidates; + bool out_of_time = false; + + for (i_t pass = 0; pass < max_passes && !out_of_time; ++pass) { + for (i_t k = 0; k < n_constraints; ++k) { + if ((k & 0xFFF) == 0 && + std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + time_budget_s) { + out_of_time = true; + break; + } + const i_t cstr_idx = row_order[k]; + const i_t row_begin = fj_cpu.h_offsets[cstr_idx]; + const i_t row_end = fj_cpu.h_offsets[cstr_idx + 1]; + if (row_begin == row_end) continue; + + const f_t lb = fj_cpu.h_cstr_lb[cstr_idx]; + const f_t ub = fj_cpu.h_cstr_ub[cstr_idx]; + const bool has_lb = isfinite(lb); + const bool has_ub = isfinite(ub); + if (!has_lb && !has_ub) continue; + + f_t sum = 0; + for (i_t i = row_begin; i < row_end; ++i) + sum += (f_t)fj_cpu.h_coefficients[i] * (f_t)fj_cpu.h_assignment[fj_cpu.h_variables[i]]; + + // Equality rows are driven to their bound; one-sided rows only to the side they violate. + const bool is_equality = has_lb && has_ub && std::abs(lb - ub) < tol; + f_t direction = 0; + f_t target = 0; + if (is_equality && std::abs(sum - lb) > tol) { + direction = sum < lb ? (f_t)1 : (f_t)-1; + target = lb; + } else if (has_lb && sum < lb - tol) { + direction = 1; + target = lb; + } else if (has_ub && sum > ub + tol) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, row_begin, row_end, direction, tol, candidates); + for (const auto& m : candidates) { + if (direction > 0 ? sum >= target - tol : sum <= target + tol) break; + const f_t delta = m.new_val - (f_t)fj_cpu.h_assignment[m.var]; + sum += m.coeff * delta; + fj_cpu.h_assignment[m.var] = m.new_val; + } + } + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline_violated) { + fj_cpu.h_assignment = anchor_assignment; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Repeated one-sided row repair in CSR order. Unlike the covering seed it revisits rows until a +// pass changes nothing, so a repair that breaks a row already visited gets another chance. +template +static void apply_aggressive_constraint_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_aggressive_budget_s; + }; + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + const f_t tol = 1e-6; + std::vector> candidates; + + for (i_t pass = 0; pass < fj_aggressive_passes && !timed_out(); ++pass) { + i_t moves = 0; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFF) == 0 && timed_out()) break; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (begin == end) continue; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + const bool has_lb = isfinite(lb); + const bool has_ub = isfinite(ub); + if (!has_lb && !has_ub) continue; + + f_t sum = 0; + for (i_t p = begin; p < end; ++p) + sum += (f_t)fj_cpu.h_coefficients[p] * (f_t)fj_cpu.h_assignment[fj_cpu.h_variables[p]]; + + f_t direction = 0; + f_t target = 0; + if (has_lb && sum < lb - tol) { + direction = 1; + target = lb; + } else if (has_ub && sum > ub + tol) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, begin, end, direction, tol, candidates); + for (const auto& move : candidates) { + if (direction > 0 ? sum >= target - tol : sum <= target + tol) break; + const f_t delta = move.new_val - (f_t)fj_cpu.h_assignment[move.var]; + sum += move.coeff * delta; + fj_cpu.h_assignment[move.var] = move.new_val; + ++moves; + } + } + if (moves == 0) break; + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Treats the exact-one rows as a graph in which each variable is an edge between the two rows it +// appears in. A component that is bipartite and has equally many rows on each side admits a perfect +// matching, and the cheapest one is the assignment satisfying every row in the component at least +// cost. Solved per component as min-cost flow by successive shortest paths, which needs no +// potentials here because augmenting along shortest paths keeps the residual free of negative +// cycles. Components that are not of that shape are left to the search. +template +static void apply_bipartite_matching_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_matching_budget_s; + }; + const f_t tol = 1e-6; + + struct exact_one_row_t { + i_t begin, end; + }; + std::vector rows; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFFF) == 0 && timed_out()) return; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + if (!isfinite(lb) || !isfinite(ub) || std::abs(lb - ub) > tol) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (begin == end || end - begin > fj_matching_max_row_width) continue; + + const f_t scale = fj_cpu.h_coefficients[begin]; + if (!isfinite(scale) || std::abs(scale) <= tol || std::abs(lb / scale - 1) > 1e-5) continue; + + bool uniform_binary = true; + for (i_t p = begin; p < end && uniform_binary; ++p) { + const f_t coeff = fj_cpu.h_coefficients[p]; + const f_t agreement = tol * std::max((f_t)1, std::abs(scale)); + uniform_binary = fj_cpu.h_is_binary_variable[fj_cpu.h_variables[p]] && + std::abs(coeff - scale) <= agreement; + } + if (uniform_binary) rows.push_back({begin, end}); + } + if (rows.size() < 2 || timed_out()) return; + + const i_t n_rows = (i_t)rows.size(); + const i_t n_variables = fj_cpu.view.pb.n_variables; + std::vector degree(n_variables, 0); + std::vector endpoint_a(n_variables, -1); + std::vector endpoint_b(n_variables, -1); + for (i_t row = 0; row < n_rows; ++row) { + for (i_t p = rows[row].begin; p < rows[row].end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + if (degree[var] == 0) endpoint_a[var] = row; + else if (degree[var] == 1) endpoint_b[var] = row; + ++degree[var]; + } + } + + struct edge_t { + int to, reverse, capacity; + f_t cost; + i_t var; + }; + auto add_edge = [](std::vector>& graph, int from, int to, f_t cost, i_t var) { + const int back = (int)graph[to].size(); + graph[from].push_back({to, back, 1, cost, var}); + graph[to].push_back({from, (int)graph[from].size() - 1, 0, -cost, -1}); + }; + + std::vector color(n_rows, -1); + std::vector state(n_variables, -1); + std::vector side_index(n_rows, -1); + std::vector component_rows, component_vars, left, right; + std::queue pending; + bool installed = false; + + for (i_t root = 0; root < n_rows && !timed_out(); ++root) { + if (color[root] >= 0) continue; + + component_rows.clear(); + component_vars.clear(); + color[root] = 0; + pending.push(root); + bool valid = true; + while (!pending.empty()) { + const i_t row = pending.front(); + pending.pop(); + component_rows.push_back(row); + for (i_t p = rows[row].begin; p < rows[row].end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + // A variable outside exactly two rows is not an edge, and a self-loop cannot be 2-coloured. + if (degree[var] != 2 || endpoint_a[var] == endpoint_b[var]) { + valid = false; + continue; + } + if (endpoint_a[var] == row) component_vars.push_back(var); + const i_t other = endpoint_a[var] == row ? endpoint_b[var] : endpoint_a[var]; + if (color[other] < 0) { + color[other] = 1 - color[row]; + pending.push(other); + } else if (color[other] == color[row]) { + valid = false; + } + } + if ((component_rows.size() & 0x3FF) == 0 && timed_out()) return; + } + if (!valid || component_vars.empty()) continue; + + left.clear(); + right.clear(); + for (i_t row : component_rows) + (color[row] == 0 ? left : right).push_back(row); + if (left.size() != right.size()) continue; + for (i_t k = 0; k < (i_t)left.size(); ++k) + side_index[left[k]] = k; + for (i_t k = 0; k < (i_t)right.size(); ++k) + side_index[right[k]] = k; + + const int side = (int)left.size(); + const int source = 2 * side; + const int sink = source + 1; + std::vector> graph(sink + 1); + for (int k = 0; k < side; ++k) { + add_edge(graph, source, k, 0, -1); + add_edge(graph, side + k, sink, 0, -1); + } + // Every perfect matching uses exactly one variable edge per row, so shifting all of them by a + // constant moves every matching's cost equally and leaves the cheapest one unchanged. Shifting + // the negatives away is what lets the potentials below start at zero. + f_t cheapest = 0; + for (i_t var : component_vars) { + const f_t cost = fj_cpu.h_obj_coeffs[var]; + if (!isfinite(cost)) { + valid = false; + break; + } + cheapest = std::min(cheapest, cost); + } + if (!valid) continue; + const f_t shift = -cheapest; + + for (i_t var : component_vars) { + i_t a = endpoint_a[var]; + i_t b = endpoint_b[var]; + if (color[a] == 1) std::swap(a, b); + add_edge(graph, side_index[a], side + side_index[b], fj_cpu.h_obj_coeffs[var] + shift, var); + } + + // Node potentials hold every reduced cost at or above zero, which is what makes Dijkstra + // applicable. All shifted costs start non-negative, so the potentials start at zero. Rounding + // can still leave a tree edge fractionally negative once the potentials move, so relaxation + // below skips settled nodes: that keeps every predecessor older than its successor in + // settlement order, which is what makes the retrace terminate. + int flow = 0; + std::vector potential(graph.size(), 0); + std::vector distance(graph.size()); + std::vector previous_node(graph.size()); + std::vector previous_edge(graph.size()); + std::vector settled(graph.size()); + using heap_entry_t = std::pair; + + while (flow < side && !timed_out()) { + std::fill(distance.begin(), distance.end(), std::numeric_limits::infinity()); + std::fill(previous_node.begin(), previous_node.end(), -1); + std::fill(settled.begin(), settled.end(), 0); + distance[source] = 0; + std::priority_queue, std::greater> heap; + heap.push({0, source}); + + while (!heap.empty()) { + const auto [reached_at, from] = heap.top(); + heap.pop(); + if (settled[from]) continue; + settled[from] = 1; + for (int e = 0; e < (int)graph[from].size(); ++e) { + const auto& edge = graph[from][e]; + if (!edge.capacity || settled[edge.to]) continue; + const f_t reduced = edge.cost + potential[from] - potential[edge.to]; + cuopt_assert(reduced >= -1e-9 * std::max((f_t)1, std::abs(edge.cost)), + "potentials failed to keep the reduced cost non-negative"); + if (reached_at + reduced >= distance[edge.to]) continue; + distance[edge.to] = reached_at + reduced; + previous_node[edge.to] = from; + previous_edge[edge.to] = e; + heap.push({distance[edge.to], edge.to}); + } + } + if (previous_node[sink] < 0) break; + + for (int node = 0; node < (int)graph.size(); ++node) + if (isfinite(distance[node])) potential[node] += distance[node]; + + for (int node = sink; node != source; node = previous_node[node]) { + cuopt_assert(previous_node[node] >= 0, "augmenting path is broken"); + auto& edge = graph[previous_node[node]][previous_edge[node]]; + --edge.capacity; + ++graph[node][edge.reverse].capacity; + } + ++flow; + } + if (flow != side) continue; + + for (i_t var : component_vars) + state[var] = 0; + for (int node = 0; node < side; ++node) + for (const auto& edge : graph[node]) + if (edge.var >= 0 && edge.capacity == 0) state[edge.var] = 1; + installed = true; + } + if (!installed) return; + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + for (i_t var = 0; var < n_variables; ++var) + if (state[var] >= 0) fj_cpu.h_assignment[var] = state[var]; + + recompute_lhs(fj_cpu); + const i_t candidate = fj_cpu.violated_constraints.size(); + // Kept when it reaches feasibility outright, otherwise only on a strict gain. + if (candidate != 0 && candidate >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Every variable to its lower bound, or its upper where the lower is infinite. +template +static void apply_lower_bound_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + for (i_t var_idx = 0; var_idx < fj_cpu.view.pb.n_variables; ++var_idx) { + auto bounds = fj_cpu.h_var_bounds[var_idx].get(); + const f_t lower = get_lower(bounds); + const f_t upper = get_upper(bounds); + if (!isfinite(lower) && !isfinite(upper)) continue; + + f_t new_val = isfinite(lower) ? lower : upper; + if (is_integer_var(fj_cpu, var_idx)) new_val = std::round(new_val); + fj_cpu.h_assignment[var_idx] = new_val; + } + + recompute_lhs(fj_cpu); + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// Constructively satisfies the equality rows that read as sum(x) = k over binaries sharing one +// coefficient: pick k members of each, narrowest rows first so the wide ones inherit the choices, +// and within a row the variables appearing in fewest other such rows. +template +static void apply_exact_k_seed(fj_cpu_climber_t& fj_cpu) +{ + if (fj_cpu.view.pb.nnz > fj_seed_nnz_limit) return; + + const auto started = std::chrono::steady_clock::now(); + auto timed_out = [&] { + return std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_exact_k_budget_s; + }; + + struct exact_k_row_t { + i_t k, begin, end; + }; + std::vector rows; + for (i_t row = 0; row < fj_cpu.view.pb.n_constraints; ++row) { + if ((row & 0xFFF) == 0 && timed_out()) return; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + if (!isfinite(lb) || !isfinite(ub) || std::abs(lb - ub) > fj_exact_k_tol) continue; + + const i_t begin = fj_cpu.h_offsets[row]; + const i_t end = fj_cpu.h_offsets[row + 1]; + if (end - begin < 2 || end - begin > fj_exact_k_max_width) continue; + + const f_t scale = fj_cpu.h_coefficients[begin]; + if (scale <= 0) continue; + bool uniform_binary = true; + for (i_t p = begin; p < end && uniform_binary; ++p) { + const i_t var = fj_cpu.h_variables[p]; + const f_t coeff = fj_cpu.h_coefficients[p]; + const f_t agreement = fj_exact_k_tol * std::max((f_t)1, std::abs(scale)); + uniform_binary = + fj_cpu.h_is_binary_variable[var] && coeff > 0 && std::abs(coeff - scale) <= agreement; + } + if (!uniform_binary) continue; + + const double cardinality = (double)lb / scale; + const i_t k = (i_t)std::lround(cardinality); + if (std::abs(cardinality - k) <= 1e-4 && k >= 0 && k <= end - begin) + rows.push_back({k, begin, end}); + } + if (rows.empty()) return; + + std::sort(rows.begin(), rows.end(), [](const exact_k_row_t& a, const exact_k_row_t& b) { + return a.end - a.begin < b.end - b.begin; + }); + + const i_t n_variables = fj_cpu.view.pb.n_variables; + std::vector degree(n_variables, 0); + for (const auto& row : rows) + for (i_t p = row.begin; p < row.end; ++p) + ++degree[fj_cpu.h_variables[p]]; + + std::vector state(n_variables, -1); + std::vector free_vars; + for (size_t index = 0; index < rows.size(); ++index) { + if ((index & 0xFFF) == 0 && timed_out()) break; + const auto& row = rows[index]; + + i_t selected = 0; + free_vars.clear(); + for (i_t p = row.begin; p < row.end; ++p) { + const i_t var = fj_cpu.h_variables[p]; + selected += state[var] == 1; + if (state[var] < 0) free_vars.push_back(var); + } + const i_t needed = row.k - selected; + if (needed < 0 || (i_t)free_vars.size() < needed) continue; + + std::sort(free_vars.begin(), free_vars.end(), [°ree](i_t a, i_t b) { + return degree[a] < degree[b]; + }); + for (i_t p = 0; p < (i_t)free_vars.size(); ++p) + state[free_vars[p]] = (int8_t)(p < needed); + } + + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + const auto anchor = fj_cpu.h_assignment; + for (i_t var = 0; var < n_variables; ++var) + if (state[var] >= 0) fj_cpu.h_assignment[var] = state[var]; + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// One repair pass over the violated rows of a start that is mostly violated. Row sums are read from +// the lhs computed on entry, so a row does not see the repairs made for earlier rows; the revert +// below is what keeps that myopia from costing anything. +template +static void repair_difficult_anchor(fj_cpu_climber_t& fj_cpu) +{ + recompute_lhs(fj_cpu); + const i_t baseline = fj_cpu.violated_constraints.size(); + if (baseline == 0 || baseline <= fj_cpu.view.pb.n_constraints / fj_anchor_repair_violated_share) + return; + + const auto started = std::chrono::steady_clock::now(); + const auto anchor = fj_cpu.h_assignment; + const std::vector violated(fj_cpu.violated_constraints.begin(), + fj_cpu.violated_constraints.end()); + std::vector> candidates; + + for (i_t row : violated) { + if (std::chrono::duration(std::chrono::steady_clock::now() - started).count() > + fj_anchor_repair_budget_s) + break; + + const f_t lb = fj_cpu.h_cstr_lb[row]; + const f_t ub = fj_cpu.h_cstr_ub[row]; + f_t sum = fj_cpu.h_lhs[row]; + f_t target = 0; + f_t direction = 0; + if (sum < lb) { + direction = 1; + target = lb; + } else if (sum > ub) { + direction = -1; + target = ub; + } else { + continue; + } + + collect_row_repair_moves(fj_cpu, + fj_cpu.h_offsets[row], + fj_cpu.h_offsets[row + 1], + direction, + fj_exact_k_tol, + candidates); + for (const auto& move : candidates) { + if (direction > 0 ? sum >= target : sum <= target) break; + const f_t delta = move.new_val - (f_t)fj_cpu.h_assignment[move.var]; + sum += move.coeff * delta; + fj_cpu.h_assignment[move.var] = move.new_val; + } + } + + recompute_lhs(fj_cpu); + if ((i_t)fj_cpu.violated_constraints.size() >= baseline) { + fj_cpu.h_assignment = anchor; + recompute_lhs(fj_cpu); + } + fj_cpu.h_best_assignment = fj_cpu.h_assignment; +} + +// What makes one lane of a CPUFJ portfolio behave differently from another: which corner it starts +// from, how it samples, and how hard it pulls on the objective. Lane 0 keeps the anchor assignment +// so it is the lane every clone is built from. +template +void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, int64_t base_seed) +{ + // Objective pressure across the portfolio, indexed by lane. Lanes 0 and 3 stay pure feasibility + // seekers until they cross, since the objective term only enters the score once the weight is + // positive; their nonzero floor then keeps a pull on the objective afterwards rather than letting + // smooth_weights decay it back to nothing. + const f_t obj_weight_ladder[4] = {0, 4, 32, 0}; + const f_t obj_weight_floor[4] = {1, 4, 32, 1}; + + // One structural start per lane; lanes 0, 4 and 7 keep the shared anchor here. Lane 4's is + // replaced inside its own task by the LP pump, so construction does not wait on an LP. + climber.use_lp_seed = lane % 8 == 4; + + // Half the portfolio searches the propagated model, half the model as parsed. + climber.use_bound_prop = lane % 2 == 0; + + climber.use_weight_donation = (lane % 8 == 5) || (lane % 8 == 6); + + // Only where the colouring came out; n_colors is zero when the structure declined it. + climber.use_move_batching = + climber.n_colors > 0 && ((lane % 8 == 2) || (lane % 8 == 6)); + climber.use_move_batching = true; + if (climber.n_colors == 0) climber.use_move_batching = false; + switch (lane % 8) { + case 1: apply_lock_weighted_seed(climber); break; + case 2: apply_aggressive_constraint_seed(climber); break; + case 3: apply_greedy_covering_seed(climber); break; + case 5: apply_bipartite_matching_seed(climber); break; + case 6: apply_objective_corner_seed(climber); break; + default: break; + } + + // Default: every climber identical apart from its seed and a random draw of the + // four sampling parameters. Diversification, decorrelated from the value RNG. + std::mt19937 rng(base_seed + 7919u * lane); + climber.mtm_viol_samples = std::uniform_int_distribution(15, 50)(rng); + climber.mtm_sat_samples = std::uniform_int_distribution(10, 30)(rng); + climber.nnz_samples = std::uniform_int_distribution(2000, 15000)(rng); + climber.perturb_interval = std::uniform_int_distribution(50, 500)(rng); + //climber.perturb_vars = std::uniform_int_distribution(2, 8)(rng); + + // The objective weight below is inert until a lane crosses, so without these the whole portfolio + // runs one weight decay, one tabu tenure and one restart policy while it is still infeasible. + // const double smoothing_ladder[8] = {0.0003, 0.0, 0.001, 0.003, 0.0001, 0.0006, 0.002, 0.0003}; + // const int tabu_min_ladder[8] = {3, 1, 5, 3, 2, 6, 4, 3}; + // const int tabu_max_ladder[8] = {13, 7, 21, 13, 10, 25, 17, 13}; + // const i_t restart_window_ladder[8] = {300, 150, 500, 300, 200, 600, 400, 300}; + // const f_t degrade_ratio_ladder[8] = {1.15, 1.05, 1.30, 1.15, 1.08, 1.40, 1.20, 1.15}; + // climber.settings.parameters.weight_smoothing_probability = smoothing_ladder[lane % 8]; + // climber.settings.parameters.tabu_tenure_min = tabu_min_ladder[lane % 8]; + // climber.settings.parameters.tabu_tenure_max = tabu_max_ladder[lane % 8]; + // climber.infeasible_restart_window = restart_window_ladder[lane % 8]; + // climber.infeasible_restart_degrade_ratio = degrade_ratio_ladder[lane % 8]; + + climber.enable_infeasible_repair = (lane % 8 == 1) || (lane % 8 == 5); + + climber.h_objective_weight = obj_weight_ladder[lane % 4]; + //climber.seed_objective_weight = obj_weight_floor[lane % 4]; +} + +// Portfolio construction for the standalone benchmark. Host logic, but it lives +// in a .cu because fj_cpu.cuh pulls in raft/util/cuda_dev_essentials.cuh through +// solution.cuh, which does not compile under the host compiler. Kept out of the +// header regardless: editing this file rebuilds one translation unit rather than +// the fifteen that including headers pull in. +template +void build_climber_portfolio(problem_t& problem, + solution_t& solution, + std::vector>& preemption_flags, + std::vector>>& climbers, + int64_t base_seed) +{ + const int n_climbers = static_cast(climbers.size()); + + for (int k = 0; k < n_climbers; ++k) + preemption_flags[k].store(false); + + // cuopt::seed_generator::get_seed() steps a non-atomic global, so every lane's seed is drawn here + // in lane order before any concurrent construction below. + std::vector lane_seed(n_climbers); + for (int k = 0; k < n_climbers; ++k) + lane_seed[k] = cuopt::seed_generator::get_seed(); + + // Lane 0 is a genuine dependency: it host-copies the problem and every other lane clones it. + { + fj_settings_t settings; + settings.seed = (int)lane_seed[0]; + climbers[0] = init_fj_cpu_standalone(problem, solution, preemption_flags[0], settings); + // Runs before the clones are taken, so every lane starts from the repaired anchor. + apply_exact_k_seed(*climbers[0]); + repair_difficult_anchor(*climbers[0]); + apply_lane_diversification(*climbers[0], 0, base_seed); + } + + // The remaining lanes depend only on lane 0's finished, read-only template, and the O(nnz) clone + // and seed passes are otherwise paid serially on one thread while the other pinned CPUs idle. +#ifdef _OPENMP +#pragma omp parallel for num_threads(std::max(1, n_climbers - 1)) schedule(static) +#endif + for (int k = 1; k < n_climbers; ++k) { + fj_settings_t settings; + settings.seed = (int)lane_seed[k]; + climbers[k] = init_fj_cpu_clone(*climbers[0], preemption_flags[k], settings); + apply_lane_diversification(*climbers[k], k, base_seed); + } + + auto shared = std::make_shared>(); + for (int k = 0; k < n_climbers; ++k) + climbers[k]->shared_incumbent = shared; +} + +#if MIP_INSTANTIATE_FLOAT +template void apply_lane_diversification(fj_cpu_climber_t&, int, int64_t); +template void build_climber_portfolio( + problem_t&, solution_t&, std::vector>&, + std::vector>>&, int64_t); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template void apply_lane_diversification(fj_cpu_climber_t&, int, int64_t); +template void build_climber_portfolio( + problem_t&, solution_t&, std::vector>&, + std::vector>>&, int64_t); +#endif + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh index 718c89615d..df86602d45 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu.cuh @@ -8,8 +8,11 @@ #pragma once #include +#include #include #include +#include +#include #include #include @@ -20,6 +23,105 @@ namespace cuopt::mathematical_optimization::mip { +template +class probing_cache_t; + +template +struct host_contiguous_set_t { + void resize(i_t max_size) + { + cuopt_assert(max_size >= 0, "invalid max size"); + contents.clear(); + contents.reserve(max_size); + index_map.assign(max_size, -1); + is_member.assign(max_size, 0); + } + + void clear() + { + for (i_t val : contents) { + index_map[val] = -1; + is_member[val] = 0; + } + contents.clear(); + } + + void insert(i_t val) + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + cuopt_assert(!contains(val), "Value already exists"); + index_map[val] = contents.size(); + is_member[val] = 1; + contents.push_back(val); + } + + void remove(i_t val) + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + cuopt_assert(contains(val), "Value not found"); + const i_t idx = index_map[val]; + const i_t last_val = contents.back(); + contents[idx] = last_val; + index_map[last_val] = idx; + contents.pop_back(); + index_map[val] = -1; + is_member[val] = 0; + } + + bool contains(i_t val) const + { + cuopt_assert(val >= 0 && val < max_size(), "Value is out of bounds"); + return is_member[val] != 0; + } + + auto begin() const { return contents.begin(); } + auto end() const { return contents.end(); } + i_t size() const { return contents.size(); } + i_t max_size() const { return index_map.size(); } + bool empty() const { return contents.empty(); } + + std::vector contents; + std::vector index_map; + std::vector is_member; +}; + +constexpr double fj_obj_mult_min = 0.25; +constexpr double fj_obj_mult_max = 4.0; + +// Best feasible assignment found by any lane of one portfolio. A lane publishes its own +// improvements and adopts a better one when it perturbs, so a lane that has stalled resumes from +// the portfolio's progress instead of its own. Lanes run concurrently, so which lane observes +// which incumbent depends on scheduling: a portfolio that shares is not run-to-run reproducible. +template +struct fj_cpu_shared_incumbent_t { + // True when the candidate beat the shared best, in which case it was stored. + bool publish(f_t candidate_objective, const std::vector& candidate) + { + // Unlocked reject first: the publish sites are hot on instances that improve in tiny steps. + if (!(candidate_objective < objective.load(std::memory_order_relaxed))) return false; + std::lock_guard lock(guard); + if (!(candidate_objective < objective.load(std::memory_order_relaxed))) return false; + assignment = candidate; + objective.store(candidate_objective, std::memory_order_relaxed); + return true; + } + + // True when the shared best beat local_objective, in which case it was copied into destination. + bool adopt(f_t local_objective, std::vector& destination) + { + if (!(objective.load(std::memory_order_relaxed) < local_objective)) return false; + std::lock_guard lock(guard); + if (!(objective.load(std::memory_order_relaxed) < local_objective)) return false; + cuopt_assert(assignment.size() == destination.size(), "shared incumbent size mismatch"); + destination = assignment; + return true; + } + + std::mutex guard; + std::vector assignment; + std::atomic objective{std::numeric_limits::infinity()}; +}; + // NOTE: this seems an easy pick for reflection/xmacros once this is available (C++26?) // Maintaining a single source of truth for all members would be nice template @@ -40,10 +142,17 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_var_bounds), ADD_INSTRUMENTED(h_cstr_lb), ADD_INSTRUMENTED(h_cstr_ub), + ADD_INSTRUMENTED(h_cstr_tolerance), ADD_INSTRUMENTED(h_var_types), ADD_INSTRUMENTED(h_is_binary_variable), ADD_INSTRUMENTED(h_objective_vars), ADD_INSTRUMENTED(h_binary_indices), + ADD_INSTRUMENTED(h_related_variables), + ADD_INSTRUMENTED(h_related_variables_offsets), + ADD_INSTRUMENTED(h_binrow_offsets), + ADD_INSTRUMENTED(h_binrow_vars), + ADD_INSTRUMENTED(h_original_ids), + ADD_INSTRUMENTED(h_reverse_original_ids), ADD_INSTRUMENTED(h_tabu_nodec_until), ADD_INSTRUMENTED(h_tabu_noinc_until), ADD_INSTRUMENTED(h_tabu_lastdec), @@ -54,8 +163,8 @@ struct fj_cpu_climber_t { ADD_INSTRUMENTED(h_cstr_right_weights), ADD_INSTRUMENTED(h_assignment), ADD_INSTRUMENTED(h_best_assignment), - ADD_INSTRUMENTED(cached_cstr_bounds), - ADD_INSTRUMENTED(iter_mtm_vars)}; + ADD_INSTRUMENTED(h_best_infeasible_assignment), + ADD_INSTRUMENTED(cached_cstr_bounds)}; #undef ADD_INSTRUMENTED } @@ -67,6 +176,7 @@ struct fj_cpu_climber_t { problem_t* pb_ptr; fj_settings_t settings; + std::mt19937 rng; typename fj_t::climber_data_t::view_t view; // Host copies of device data as struct members ins_vector h_reverse_coefficients; @@ -79,10 +189,22 @@ struct fj_cpu_climber_t { ins_vector::type> h_var_bounds; ins_vector h_cstr_lb; ins_vector h_cstr_ub; + // get_corrected_tolerance of each row, held because the bounds it derives from never move. + ins_vector h_cstr_tolerance; ins_vector h_var_types; ins_vector h_is_binary_variable; ins_vector h_objective_vars; ins_vector h_binary_indices; + ins_vector h_related_variables; + ins_vector h_related_variables_offsets; + + // precompute the binary variables per row for bin 2opt + ins_vector h_binrow_offsets; + ins_vector h_binrow_vars; + const probing_cache_t* probing_cache{nullptr}; + // Probing cache keys are pre-trivial-presolve variable ids; these translate to and from them + ins_vector h_original_ids; + ins_vector h_reverse_original_ids; ins_vector h_tabu_nodec_until; ins_vector h_tabu_noinc_until; @@ -97,15 +219,50 @@ struct fj_cpu_climber_t { ins_vector h_assignment; ins_vector h_best_assignment; f_t h_objective_weight; + // Lower bound h_objective_weight decays to, so a lane seeded with objective pressure keeps it. + f_t seed_objective_weight{0}; + // Mean absolute nonzero objective coefficient; the unit of the objective score term. + f_t obj_magnitude{1}; f_t h_incumbent_objective; + // Kahan compensation for h_incumbent_objective, mirroring h_lhs_sumcomp. Reset wherever the + // objective is re-derived from the assignment. + f_t h_objective_sumcomp{0}; f_t h_best_objective; - i_t last_feasible_entrance_iter{0}; i_t iterations; - std::unordered_set violated_constraints; - std::unordered_set satisfied_constraints; + host_contiguous_set_t violated_constraints; + host_contiguous_set_t satisfied_constraints; bool feasible_found{false}; bool trigger_early_lhs_recomputation{false}; + + // Move batching over a colouring of the variable co-occurrence graph, where each row is a clique. + // Same colour means no shared row, so a batch of same-coloured moves has disjoint row support. + bool use_move_batching{false}; + i_t n_colors{0}; + std::vector h_var_color; + // Per variable, the best move seen since the epoch below, and the sum of its incident row + // versions at that moment. The entry is usable while both still match. + std::vector h_var_best_score; + std::vector h_var_best_delta; + std::vector h_var_best_stamp; + std::vector h_var_best_rowsum; + int64_t var_best_epoch{1}; + // Variables that entered the table with a positive score, bucketed by colour. Stale entries are + // skipped at selection, so each bucket carries the epoch it was last cleared in. + std::vector> h_color_candidates; + std::vector h_color_epoch; + // Membership is stamped separately from validity: a variable consumed by a batch is invalidated + // while staying in its bucket, so it cannot be enqueued twice in one epoch. + std::vector h_var_bucket_stamp; + int64_t n_batch_attempts{0}; + int64_t n_batched_moves{0}; + // Companions per attempt, in unit bins. The last bin saturates, so max_batch_size carries the + // tail exactly. + std::vector batch_size_hist; + int64_t max_batch_size{0}; f_t total_violations{0}; + // Kahan compensation for total_violations, mirroring h_lhs_sumcomp. Reset wherever the total is + // re-derived from the violated set. + f_t total_violations_sumcomp{0}; // Timing data structures std::vector find_lift_move_times; @@ -115,29 +272,89 @@ struct fj_cpu_climber_t { std::vector update_weights_times; std::vector compute_score_times; - i_t hit_count{0}; - i_t miss_count{0}; + int64_t hit_count{0}; + int64_t miss_count{0}; i_t candidate_move_hits[3] = {0}; i_t candidate_move_misses[3] = {0}; - // vector is actually likely beneficial here since we're memory bound - std::vector flip_move_computed; + // Hot-loop accounting, reported off the clock by the standalone harness. + int64_t n_moves_applied{0}; + int64_t apply_move_nnz{0}; + int64_t n_mtm_calls{0}; + // Row entries find_mtm_move visits, and the ones the per-row cap kept it from visiting. + int64_t mtm_row_entries{0}; + int64_t mtm_entries_capped{0}; + int64_t n_compute_score_calls{0}; + int64_t compute_score_nnz{0}; + int64_t n_version_bumps_apply{0}; + int64_t n_version_bumps_weights{0}; + int64_t n_mtm_cache_invalidations{0}; + int64_t n_lhs_recompute_total{0}; + int64_t n_lhs_recompute_periodic{0}; + int64_t n_lhs_recompute_bigval{0}; + int64_t n_lhs_recompute_perturb{0}; + int64_t n_lhs_recompute_restart{0}; + i_t lhs_refresh_period_used{0}; + + // A variable's flip move has already been considered when its stamp equals flip_move_epoch, + // which advances once per applied move. An epoch avoids clearing an n_variables bitmap per move. + std::vector flip_move_stamp; + int64_t flip_move_epoch{1}; + + // Continuous objective variables bounded by their rows only opposite the objective's pull, so + // the tightest row bound is their value. epigraph_push is +1 pushing up, -1 pushing down. + std::vector epigraph_push; + std::vector epigraph_vars; + int64_t n_epigraph_projections{0}; // CSR nnz offset -> (delta, score) std::vector> cached_mtm_moves; + // Entry i is live only while cached_mtm_moves_version[i] == h_cstr_version of i's row. + std::vector cached_mtm_moves_version; + std::vector h_cstr_version; + // CSC (transposed!) nnz-offset-indexed constraint bounds (lb, ub) // std::pair better compile down to 16 bytes!! GCC do your job! ins_vector> cached_cstr_bounds; - std::vector var_bitmap; - ins_vector iter_mtm_vars; + // Scratch reused by the binary 2-opt search, which runs at every local minimum + std::vector two_opt_target_cstrs; + std::vector two_opt_first_vars; + std::vector> two_opt_partners; + std::vector> two_opt_row_deltas; + + ins_vector h_best_infeasible_assignment; + f_t best_infeasible_severity{std::numeric_limits::infinity()}; + f_t checkpoint_severity{std::numeric_limits::infinity()}; + i_t iters_since_infeasible_improve{0}; + i_t restores_since_improvement{0}; + i_t max_restores_since_improvement{0}; + int64_t n_checkpoint_restores{0}; + int64_t n_checkpoint_snapshots{0}; i_t mtm_viol_samples{25}; i_t mtm_sat_samples{15}; i_t nnz_samples{50000}; i_t perturb_interval{100}; + // Number of variables randomized by one perturbation. + i_t perturb_vars{2}; + // One lane replaces its start with a rounded LP relaxation, solved inside that lane's own task so + // portfolio construction does not wait on an LP. + bool use_lp_seed{false}; + // Half the lanes narrow their own domains by activity propagation before searching, so the + // portfolio covers both the propagated and the as-parsed model. + bool use_bound_prop{false}; + // Two lanes move weight from satisfied rows into the violated ones while still infeasible. + bool use_weight_donation{false}; + // Enables the binary engine's infeasible-phase pair repair. Per lane, since the pair scan costs + // iterations that a well-tuned single-flip lane would rather spend elsewhere. + bool enable_infeasible_repair{false}; + i_t infeasible_restart_window{300}; + i_t infeasible_restart_max_streak{20}; + f_t infeasible_restart_degrade_ratio{1.15}; + f_t infeasible_checkpoint_refresh_ratio{0.99}; i_t log_interval{1000}; i_t diversity_callback_interval{3000}; @@ -149,6 +366,10 @@ struct fj_cpu_climber_t { std::function&)> diversity_callback{nullptr}; std::string log_prefix{""}; + // Held with the other lanes of the same portfolio. Null when the climber runs alone, which is + // what keeps a solo climber reproducible. + std::shared_ptr> shared_incumbent; + // Work unit tracking for deterministic synchronization std::atomic work_units_elapsed{0.0}; double work_unit_bias{1.5}; // Bias factor to keep CPUFJ ahead of B&B @@ -168,8 +389,8 @@ struct fj_cpu_climber_t { i_t iterations_since_best{0}; // Cache and locality tracking - i_t hit_count_window_start{0}; - i_t miss_count_window_start{0}; + int64_t hit_count_window_start{0}; + int64_t miss_count_window_start{0}; std::unordered_set unique_cstrs_accessed_window; std::unordered_set unique_vars_accessed_window; @@ -204,4 +425,36 @@ std::unique_ptr> init_fj_cpu_standalone( std::atomic& preemption_flag, fj_settings_t settings = fj_settings_t{}); +// Copies a climber that has already paid the O(nnz) problem construction. Everything the engine +// reads is host-owned, so this needs neither a problem handle nor any GPU work. +template +std::unique_ptr> init_fj_cpu_clone( + const fj_cpu_climber_t& tmpl, + std::atomic& preemption_flag, + fj_settings_t settings = fj_settings_t{}); + +// Per-lane behaviour for a CPUFJ portfolio, shared by every caller that races several climbers so +// the composition cannot drift between them. +template +void apply_lane_diversification(fj_cpu_climber_t& climber, int lane, int64_t base_seed); + +// Builds the climber portfolio the standalone benchmark races: how many distinct +// behaviours, what parameters each gets, whether they are randomized or +// specialized. Defined in fj_cpu_portfolio.cpp -- host code, compiled by the host +// compiler, so editing it is markedly cheaper than editing this header. Runs +// inside the measured window. +template +void build_climber_portfolio(problem_t& problem, + solution_t& solution, + std::vector>& preemption_flags, + std::vector>>& climbers, + int64_t base_seed); + +template +std::unique_ptr> init_fj_cpu_from_optimization_problem( + const optimization_problem_t& problem, + const typename mip_solver_settings_t::tolerances_t& tolerances, + std::atomic& preemption_flag, + fj_settings_t settings = fj_settings_t{}); + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu new file mode 100644 index 0000000000..7fe3bb1e39 --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cu @@ -0,0 +1,2176 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "fj_cpu_binary.cuh" + +#include "feasibility_jump.cuh" +#include "fj_cpu.cuh" + +#include +#include +#include + +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +const char* fj_binary_reject_name(fj_binary_reject_t reason) +{ + switch (reason) { + case fj_binary_reject_t::none: return "none"; + case fj_binary_reject_t::empty_problem: return "empty problem"; + case fj_binary_reject_t::non_binary_var: return "non-binary variable"; + case fj_binary_reject_t::fractional_coefficient: return "fractional coefficient"; + case fj_binary_reject_t::coefficient_out_of_range: return "coefficient wider than int16"; + case fj_binary_reject_t::fractional_row_bound: return "fractional row bound"; + case fj_binary_reject_t::row_bound_out_of_range: return "row bound outside int32"; + case fj_binary_reject_t::lhs_headroom: return "row sum|coef| exceeds int32 headroom"; + case fj_binary_reject_t::narrow_check_failed: return "narrowing check failed"; + } + return "unknown"; +} + +// work unit proxy. will likely require a lot of tuning +constexpr double fj_bin_bytes_per_nnz = 16.0; + +// Tabu for binary variables, expressed as a ring buffer +// There can be at most max_tenure tabu'd variables at any given time. +// since max_tenure << n_vars, it's cheaper to maintain a ring buffer than a full array +// and it allows smaller instances to become L1 resident +struct fj_bin_tabu_t { + static constexpr int32_t ring_size = 16; + static constexpr int32_t max_tenure = ring_size; + // Headroom so iter + tenure - iter_bias still fits uint16 when iter - iter_bias is at the rebase + // threshold. + static constexpr int32_t window = + (int32_t)std::numeric_limits::max() - max_tenure; + + std::vector flip_until; + std::vector last_flip; + int32_t iter_bias{0}; + + int32_t ring_var[ring_size]; + int32_t ring_expiry[ring_size]; + + + void resize(int32_t n) + { + flip_until.assign(n, 0); + last_flip.assign(n, 0); + clear_ring(); + iter_bias = 0; + } + + void clear(int32_t iter) + { + std::fill(flip_until.begin(), flip_until.end(), (uint16_t)0); + std::fill(last_flip.begin(), last_flip.end(), 0); + clear_ring(); + iter_bias = iter; + } + + void clear_ring() + { + for (int32_t i = 0; i < ring_size; ++i) { + ring_var[i] = -1; + ring_expiry[i] = 0; + } + } + + void on_flip(int32_t v, int32_t iter, int32_t tenure) + { + flip_until[v] = (uint16_t)(iter + tenure - iter_bias); + last_flip[v] = iter; + + // keep only one tabu entry per var + for (int32_t i = 0; i < ring_size; ++i) { + if (ring_var[i] == v) ring_var[i] = -1; + } + + const int32_t slot = iter & (ring_size - 1); + ring_var[slot] = v; + ring_expiry[slot] = iter + tenure; + } + + // replace the scores of tabu'd variable with sentinel values + int32_t block_tabu(int32_t iter, + int64_t* var_score, + int32_t (&saved_var)[ring_size], + int64_t (&saved_score)[ring_size]) const + { + int32_t k = 0; + for (int32_t i = 0; i < ring_size; ++i) { + const int32_t v = ring_var[i]; + if (v >= 0 && ring_expiry[i] > iter) { + saved_var[k] = v; + saved_score[k] = var_score[v]; + var_score[v] = fj_bin_score_invalid; + ++k; + } + } + return k; + } + + // reverse the above operation. + static void unblock_tabu(int32_t k, + int64_t* var_score, + const int32_t (&saved_var)[ring_size], + const int64_t (&saved_score)[ring_size]) + { + for (int32_t i = k - 1; i >= 0; --i) var_score[saved_var[i]] = saved_score[i]; + } + + + bool blocked(int32_t v, int32_t iter, bool localmin) const + { + return localmin ? (iter == last_flip[v] + 1) + : ((uint16_t)(iter - iter_bias) < flip_until[v]); + } + + // rebase the iteration bias value every 64k iter + void maybe_rebase(int32_t iter) + { + if ((int64_t)iter - iter_bias <= window) return; + const uint16_t shift = (uint16_t)(iter - iter_bias); + for (uint16_t& fu : flip_until) fu = (fu > shift) ? (uint16_t)(fu - shift) : (uint16_t)0; + iter_bias = iter; + } +}; + +// Narrowed problem: one-sided rows, integer coefficients, CSR plus its transpose. +template +struct fj_bin_problem_t { + int32_t n_variables{0}; + int32_t n_constraints{0}; + int32_t nnz{0}; + + std::vector offsets; + std::vector variables; + std::vector coefficients; + + std::vector reverse_offsets; + std::vector reverse_constraints; + std::vector reverse_to_csr; + + // Per incidence, for the vectorized row walk: the coefficient and the row's cmax, both replicated + // in transpose order so the walk reads them at unit stride instead of gathering per row. Both are + // structural. + std::vector reverse_coefficients; + std::vector incident_row_cmax; + + std::vector bound; + std::vector cmax; + std::vector initial_weight; + + std::vector objective; + std::vector objective_vars; + + // Cardinality census, for the repair-pair gate. A cardinality row is an equality over binaries + // sharing one coefficient, so a variable of degree two across them can only be switched on by + // switching exactly one other off: the exchange a pair can represent. + int32_t n_exchange_vars{0}; + int32_t max_card_degree{0}; + + // Empty unless encoded, when every engine variable is one bit of a bounded general integer and + // original[j] = var_offset[j] + sum of bit_weight[b] * assign[b] over the bits b owned by j. + bool encoded{false}; + int32_t n_original{0}; + std::vector var_offset; + std::vector bit_owner; + std::vector bit_weight; + std::vector orig_objective; +}; + +// Result of the width-independent eligibility scan. +struct fj_bin_scan_t { + fj_binary_reject_t reject{fj_binary_reject_t::none}; + int coefficient_bits{0}; + int32_t n_split_constraints{0}; + int32_t bad_row{-1}; + int32_t bad_var{-1}; + std::vector row_scale; +}; + +constexpr int64_t fj_bin_scale_cap = std::numeric_limits::max(); + +// DDFW and restart have no general-path equivalent, so their defaults live here until there is a +// reason to promote them alongside the other FJ knobs. +constexpr int32_t fj_bin_ddfw_init = 10; // initial weight, also the donation floor +constexpr int32_t fj_bin_ddfw_transfer = 1; +constexpr int32_t fj_bin_ddfw_donor_samples = 4; +constexpr int32_t fj_bin_restart_period = 5000000; + +// Escalation threshold and step, in infeasible local minima without a severity improvement. +constexpr int32_t fj_bin_ddfw_escalate_after = 2000; +constexpr int32_t fj_bin_ddfw_escalate_max = 100; + +// The same, in feasible local minima without a best-objective improvement. +constexpr int32_t fj_bin_obj_stall_after = 50; +constexpr int32_t fj_bin_obj_escalate_max = 10; + +// Infeasible-region kick: stall, cooldown, post-restart quiet window, rows drawn, flips per row. +constexpr int32_t fj_bin_kick_after = 200; +constexpr int32_t fj_bin_kick_cooldown = 200; +constexpr int32_t fj_bin_kick_restart_guard = 50; +constexpr int32_t fj_bin_kick_rows = 3; +constexpr int32_t fj_bin_kick_vars_per_row = 2; + +// Infeasible-phase pair repair: iterations between attempts, violated rows sampled per attempt, +// and the pool size the O(pool^2) pair scan is capped to. +constexpr int32_t fj_bin_repair_interval = 20; +constexpr int32_t fj_bin_repair_max_rows = 4; +constexpr int32_t fj_bin_repair_max_vars = 12; + +// Structure the pair repair needs before it is worth running: enough variables that are shared by +// exactly two cardinality rows, and no variable shared by so many that closing the exchange takes a +// chain rather than a pair. +constexpr int32_t fj_bin_repair_min_exchange_vars = 64; +constexpr int32_t fj_bin_repair_max_card_degree = 4; + +// Candidate draws per 2-opt lift search. +constexpr int32_t fj_bin_2opt_candidates = 64; +// prefetch distance +// TODO: check if it actually matters at all for performance +constexpr int32_t fj_bin_pf_dist = 8; + +constexpr int32_t fj_bin_base_limit = 1 << 16; +constexpr int32_t fj_bin_bonus_limit = 1 << 14; + +static inline bool fj_bin_in_int32(double v) +{ + return v >= (double)INT32_MIN && v <= (double)INT32_MAX; +} + +// Tile width of the argmax sweep, in variables: min(algorithm target, L1-residency cap). +// +// The target is about the shape of the sweep rather than cache capacity -- it sets how often the +// running maximum is raised, which is what bounds the index re-scan -- and 256 is the measured +// optimum. The cap is a residency guard, and it is the reason this is not simply a constant: the +// re-scan pays off only because it revisits a tile that is still L1-hot, so the tile must not be +// wide enough to spill. It bites only on a small L1, where an unguarded 256 would push the re-scan +// out to L2 and cost more than the split saves. +// +// Bytes per variable is the score array alone. Tabu does not appear: the sweep reads var_score +// only, with the handful of tabu variables held at the invalid sentinel across it, so flip_until is +// never touched here. +constexpr int32_t fj_bin_argmax_tile_target = 256; +constexpr int32_t fj_bin_argmax_tile_cap_k = 4; + +static int32_t fj_bin_argmax_tile() +{ +#ifdef _SC_LEVEL1_DCACHE_SIZE + long l1 = sysconf(_SC_LEVEL1_DCACHE_SIZE); +#else + long l1 = 0; +#endif + if (l1 <= 0) l1 = 32768; // fallback: 32 KiB, the common x86 L1d + const int32_t bpv = (int32_t)sizeof(int32_t); + const int32_t cap = (int32_t)(l1 / (fj_bin_argmax_tile_cap_k * bpv)); + int32_t t = fj_bin_argmax_tile_target < cap ? fj_bin_argmax_tile_target : cap; + t &= ~15; // whole vectors + return t < 16 ? 16 : t; +} + +// Width-independent eligibility scan over the climber's host mirrors. Mutates nothing. +template +static fj_bin_scan_t fj_bin_scan(const fj_cpu_climber_t& c) +{ + fj_bin_scan_t out; + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + if (n <= 0 || m <= 0) { + out.reject = fj_binary_reject_t::empty_problem; + return out; + } + + const double tol = c.view.pb.tolerances.integrality_tolerance; + const auto& is_binary_variable = c.h_is_binary_variable; + cuopt_assert((int32_t)is_binary_variable.size() == n, "is_binary_variable size mismatch"); + + for (int32_t v = 0; v < n; ++v) { + // Populated at climber init with integer_equal on [0,1] bounds. + if (!is_binary_variable[v]) { + out.reject = fj_binary_reject_t::non_binary_var; + out.bad_var = v; + return out; + } + } + + const auto& offsets = c.h_offsets; + const auto& reverse_offsets = c.h_reverse_offsets; + const auto& reverse_constraints = c.h_reverse_constraints; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + + cuopt_assert( + thrust::all_of( + thrust::host, + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(n), + [&reverse_offsets, &reverse_constraints](int32_t v) { + const auto first = reverse_constraints.begin() + reverse_offsets[v]; + const auto last = reverse_constraints.begin() + reverse_offsets[v + 1]; + return std::adjacent_find(first, last) == last; + }), + "duplicate variable in CSR row"); + + double max_abs_coefficient = 0; + std::vector row_values; + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + const bool lb_fin = std::isfinite(lb); + const bool ub_fin = std::isfinite(ub); + const double sides[2] = {lb, ub}; + const bool finite[2] = {lb_fin, ub_fin}; + + bool fractional_coefficient_seen = false; + bool integral = true; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + if (!is_integer(coeffs[k], tol)) { + fractional_coefficient_seen = true; + integral = false; + break; + } + } + for (int s = 0; s < 2 && integral; ++s) { + if (finite[s] && !is_integer(sides[s], tol)) integral = false; + } + + double row_s = 1.0; + if (!integral) { + row_values.clear(); + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) row_values.push_back(coeffs[k]); + for (int s = 0; s < 2; ++s) { + if (finite[s]) row_values.push_back(sides[s]); + } + row_s = find_scaling_rational(row_values, + /*maxscale=*/1.0 / tol, + /*maxdnom=*/fj_bin_scale_cap, + /*maxfinal=*/(double)fj_bin_scale_cap, + /*intcheck_tol=*/tol); + if (!std::isfinite(row_s) || row_s <= 0.0) { + out.reject = fractional_coefficient_seen ? fj_binary_reject_t::fractional_coefficient + : fj_binary_reject_t::fractional_row_bound; + out.bad_row = r; + return out; + } + if (out.row_scale.empty()) out.row_scale.assign(m, 1.0); + out.row_scale[r] = row_s; + } + + double row_abs_sum = 0; + double row_lhs_min = 0; + double row_lhs_max = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const double a = row_s * coeffs[k]; + cuopt_assert(is_integer(a, tol), "row scaling left a fractional coefficient"); + const double integral_a = std::round(a); + const double abs_a = std::fabs(integral_a); + row_abs_sum += abs_a; + if (integral_a < 0) { + row_lhs_min += integral_a; + } else { + row_lhs_max += integral_a; + } + if (abs_a > max_abs_coefficient) max_abs_coefficient = abs_a; + } + + // A binary assignment can drive lhs to sum|coef|; keep that inside the int32 accumulator with + // room to spare. The int8-only reference engine never needed this bound. + if (row_abs_sum > (double)(INT32_MAX / 2)) { + out.reject = fj_binary_reject_t::lhs_headroom; + out.bad_row = r; + return out; + } + + for (int s = 0; s < 2; ++s) { + if (!finite[s]) continue; + const double scaled_side = row_s * sides[s]; + cuopt_assert(is_integer(scaled_side, tol), "row scaling left a fractional row bound"); + if (!fj_bin_in_int32(std::round(scaled_side))) { + out.reject = fj_binary_reject_t::row_bound_out_of_range; + out.bad_row = r; + return out; + } + const double integral_side = std::round(scaled_side); + const double min_slack = + s == 0 ? row_lhs_min - integral_side : integral_side - row_lhs_max; + const double max_slack = + s == 0 ? row_lhs_max - integral_side : integral_side - row_lhs_min; + if (!fj_bin_in_int32(min_slack) || !fj_bin_in_int32(max_slack)) { + out.reject = fj_binary_reject_t::lhs_headroom; + out.bad_row = r; + return out; + } + } + // Free rows are dropped: trivially satisfied, contributing nothing to the search. + out.n_split_constraints += (int32_t)lb_fin + (int32_t)ub_fin; + } + + if (out.n_split_constraints <= 0) { + out.reject = fj_binary_reject_t::empty_problem; + return out; + } + + if (max_abs_coefficient <= 127.0) { + out.coefficient_bits = 8; + } else if (max_abs_coefficient <= 32767.0) { + out.coefficient_bits = 16; + } else { + out.reject = fj_binary_reject_t::coefficient_out_of_range; + } + return out; +} + +// Build the narrowed, one-sided problem. Called only after fj_bin_scan cleared the instance, so a +// failing check here is a self-consistency bug and refuses the fast path rather than truncating. +template +static bool fj_bin_narrow(const fj_cpu_climber_t& c, + const fj_bin_scan_t& scan, + fj_bin_problem_t& pb) +{ + const int32_t n_split = scan.n_split_constraints; + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + const double tol = c.view.pb.tolerances.integrality_tolerance; + + const auto& offsets = c.h_offsets; + const auto& variables = c.h_variables; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + const auto& left_w = c.h_cstr_left_weights; + const auto& right_w = c.h_cstr_right_weights; + const auto& obj = c.h_obj_coeffs; + + pb.n_variables = n; + pb.n_constraints = n_split; + pb.offsets.assign(1, 0); + pb.offsets.reserve(n_split + 1); + pb.bound.reserve(n_split); + pb.cmax.reserve(n_split); + pb.initial_weight.reserve(n_split); + + std::vector incoming_weight; + incoming_weight.reserve(n_split); + + // Each split row inherits the weight of the side it came from: left is the lower-bound side, + // right the upper. + // + // Both sides are stored as a'x <= b. The lower-bound side is negated on the way in, which costs + // nothing because each side already gets its own copy of the row, and it leaves the slack as + // bound - lhs everywhere -- so no per-row sign reaches the engine at all. Negation is safe on both + // fields: the scan admits |coef| up to 127 for int8 and 32767 for int16, and the bound is checked + // for int32 range after negating. + auto emit = [&](int32_t r, double side_bound, long side, double weight) -> bool { + const double s = scan.row_scale.empty() ? 1.0 : scan.row_scale[r]; + coef_t row_cmax = 1; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const double a = s * coeffs[k]; + const long ai = side * std::lround(a); + if (!is_integer(a, tol) || ai < std::numeric_limits::min() || + ai > std::numeric_limits::max()) { + return false; + } + pb.variables.push_back(variables[k]); + pb.coefficients.push_back((coef_t)ai); + const coef_t abs_a = (coef_t)std::labs(ai); + if (abs_a > row_cmax) row_cmax = abs_a; + } + const long b = side * std::lround(s * side_bound); + if (!fj_bin_in_int32((double)b)) return false; + pb.offsets.push_back((int32_t)pb.variables.size()); + pb.bound.push_back((int32_t)b); + pb.cmax.push_back(row_cmax); + incoming_weight.push_back(weight); + return true; + }; + + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (std::isfinite(lb) && !emit(r, lb, -1, left_w[r])) return false; + if (std::isfinite(ub) && !emit(r, ub, 1, right_w[r])) return false; + } + if ((int32_t)pb.bound.size() != n_split) return false; + pb.nnz = (int32_t)pb.variables.size(); + + // One vector of padding past nnz, so the row kernel can load and store whole vectors at the last + // row without running off the end and can therefore mask its remainder rather than peeling it + // into a scalar tail. The padding is never read as data: every lane past a row's end is excluded + // from the gather, the scatter and the store by the row-length mask. + pb.variables.resize(pb.nnz + fj_bin_simd_padding, 0); + pb.coefficients.resize(pb.nnz + fj_bin_simd_padding, (coef_t)0); + + // Scale the incoming weights into the DDFW band by one global factor, so relative structure + // survives while every row clears the donation floor. Capped so the largest scaled weight stays + // clear of packed-score saturation; where the cap binds, the smallest rows sit below the floor. + // TODO: bound the scaled weights by derivation instead of leaving them open. The packed score + // holds while a variable's aggregate base stays under 2^16, and that aggregate is bounded by the + // sum of weights over the rows the variable appears in, so 2^16 / max_var_degree gives a per-row + // bound computable here from the transpose. Left uncapped for now, matching the reference + // engine, which shipped with its weight cap disabled and relied on the end-of-solve saturation + // report to say whether a bound was needed. + double w_min = std::numeric_limits::infinity(); + for (double w : incoming_weight) { + if (w > 0 && w < w_min) w_min = w; + } + double scale = 1.0; + if (std::isfinite(w_min) && w_min > 0) { + scale = (double)fj_bin_ddfw_init / w_min; + if (scale < 1.0) scale = 1.0; + } + for (double w : incoming_weight) { + int32_t scaled = w > 0 ? (int32_t)std::lround(w * scale) : fj_bin_ddfw_init; + if (scaled < 1) scaled = 1; + pb.initial_weight.push_back(scaled); + } + + // Transpose, plus the reverse-nnz to CSR-nnz map the apply path uses to store the flipped + // variable's own score delta. + pb.reverse_offsets.assign(n + 1, 0); + for (int32_t k = 0; k < pb.nnz; ++k) pb.reverse_offsets[pb.variables[k] + 1]++; + for (int32_t v = 0; v < n; ++v) pb.reverse_offsets[v + 1] += pb.reverse_offsets[v]; + pb.reverse_constraints.resize(pb.nnz); + pb.reverse_coefficients.resize(pb.nnz); + pb.reverse_to_csr.resize(pb.nnz); + pb.incident_row_cmax.resize(pb.nnz); + { + std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n); + for (int32_t r = 0; r < n_split; ++r) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t slot = cursor[pb.variables[k]]++; + pb.reverse_constraints[slot] = r; + pb.reverse_coefficients[slot] = pb.coefficients[k]; + pb.reverse_to_csr[slot] = k; + pb.incident_row_cmax[slot] = pb.cmax[r]; + } + } + } + // Lookahead room for the row walk: a vector of overhang for the kernel's unit-stride loads, and + // the prefetch distance the scalar path uses. Reads land on row 0, harmlessly, and every lane past + // a variable's range is masked out of the gather, the scatter and the compress. + const int32_t rpad = + fj_bin_pf_dist > fj_bin_simd_padding ? fj_bin_pf_dist : fj_bin_simd_padding; + pb.reverse_constraints.resize(pb.nnz + rpad, 0); + pb.reverse_coefficients.resize(pb.nnz + rpad, (coef_t)0); + pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); + + pb.objective.resize(n); + for (int32_t v = 0; v < n; ++v) { + pb.objective[v] = obj[v]; + if (pb.objective[v] != 0.0) pb.objective_vars.push_back(v); + } + + // Every variable here is binary, so an equality row whose members share one coefficient reads as + // a cardinality constraint. Counted on the unscaled row: the row scale multiplies bound and + // coefficients alike and leaves the ratio alone. + { + std::vector card_degree(n, 0); + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (!std::isfinite(lb) || !std::isfinite(ub) || std::fabs(lb - ub) > tol) continue; + + const int32_t begin = offsets[r]; + const int32_t end = offsets[r + 1]; + if (end - begin < 2) continue; + + const double shared = coeffs[begin]; + if (std::fabs(shared) <= tol) continue; + const double k = lb / shared; + if (k < 1.0 - tol || std::fabs(k - std::round(k)) > tol) continue; + + bool uniform = true; + for (int32_t p = begin; p < end && uniform; ++p) { + const double a = coeffs[p]; + uniform = std::fabs(a - shared) <= tol * std::max(1.0, std::fabs(shared)); + } + if (!uniform) continue; + + for (int32_t p = begin; p < end; ++p) + card_degree[variables[p]]++; + } + for (int32_t v = 0; v < n; ++v) { + if (card_degree[v] == 2) ++pb.n_exchange_vars; + if (card_degree[v] > pb.max_card_degree) pb.max_card_degree = card_degree[v]; + } + } + return true; +} + + +// Bit budget for one general integer's domain. +constexpr int32_t fj_bin_encode_max_bits = 16; +// Cap on the bit-variable count relative to the model's variable count, bounding the SIMD sweep. +constexpr int64_t fj_bin_encode_max_growth = 6; + +// Bits needed to represent the integers 0..W inclusive. +static inline int32_t fj_bin_encode_nbits(int64_t W) +{ + int32_t bits = 0; + while (((int64_t)1 << bits) - 1 < W) ++bits; + return bits; +} + +// Encodes an all-integer model with bounded general integers into bits: x in [L,U] becomes +// x = L + sum_k w_k b_k over weights 1, 2, ..., 2^(nbits-2), R, with R closing the range at W = U-L. +template +static bool fj_bin_encode(const fj_cpu_climber_t& c, + fj_bin_problem_t& pb, + int& coefficient_bits) +{ + const int32_t n = c.view.pb.n_variables; + const int32_t m = c.view.pb.n_constraints; + if (n <= 0 || m <= 0) return false; + + const double tol = c.view.pb.tolerances.integrality_tolerance; + + const auto& var_bounds = c.h_var_bounds; + const auto& var_types = c.h_var_types; + const auto& offsets = c.h_offsets; + const auto& variables = c.h_variables; + const auto& coeffs = c.h_coefficients; + const auto& cstr_lb = c.h_cstr_lb; + const auto& cstr_ub = c.h_cstr_ub; + const auto& left_w = c.h_cstr_left_weights; + const auto& right_w = c.h_cstr_right_weights; + const auto& obj = c.h_obj_coeffs; + + std::vector lower(n); + std::vector upper(n); + std::vector nbits(n); + std::vector bit_start(n); + int64_t total_bits = 0; + for (int32_t v = 0; v < n; ++v) { + if (var_types[v] != var_t::INTEGER) return false; + auto bounds = var_bounds[v]; + const double x = (double)cuopt::get_lower(bounds); + const double y = (double)cuopt::get_upper(bounds); + if (!std::isfinite(x) || !std::isfinite(y) || y < x) return false; + if (!is_integer(x, tol) || !is_integer(y, tol)) return false; + + lower[v] = std::round(x); + upper[v] = std::round(y); + const int64_t W = (int64_t)(upper[v] - lower[v]); + + nbits[v] = fj_bin_encode_nbits(W); + if (nbits[v] > fj_bin_encode_max_bits) return false; + bit_start[v] = (int32_t)total_bits; + total_bits += nbits[v]; + } + if (total_bits <= 0 || total_bits > (int64_t)INT32_MAX / 2) return false; + if (total_bits > fj_bin_encode_max_growth * (int64_t)n) return false; + + const int32_t n_bits = (int32_t)total_bits; + + pb.encoded = true; + pb.n_original = n; + pb.var_offset = lower; + pb.orig_objective.assign(n, 0.0); + pb.bit_owner.assign(n_bits, 0); + pb.bit_weight.assign(n_bits, 0.0); + for (int32_t v = 0; v < n; ++v) { + int64_t covered = 0; + const int64_t W = (int64_t)(upper[v] - lower[v]); + for (int32_t k = 0; k < nbits[v]; ++k) { + const int64_t w = k + 1 < nbits[v] ? (int64_t)1 << k : W - covered; + covered += w; + pb.bit_owner[bit_start[v] + k] = v; + pb.bit_weight[bit_start[v] + k] = (double)w; + } + cuopt_assert(covered == W, "bit weights do not close the domain exactly"); + } + + pb.n_variables = n_bits; + pb.offsets.assign(1, 0); + pb.bound.clear(); + pb.cmax.clear(); + pb.initial_weight.clear(); + pb.variables.clear(); + pb.coefficients.clear(); + + std::vector incoming_weight; + std::vector row_values; + double max_abs_coefficient = 0; + + // One side of one row, as a'b <= bound in bit space with sum(a_j L_j) folded into the bound. + auto emit = [&](int32_t r, double side_bound, long side, double weight) -> bool { + double fixed = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) + fixed += coeffs[k] * lower[variables[k]]; + const double folded_bound = side_bound - fixed; + + row_values.clear(); + bool integral = is_integer(folded_bound, tol); + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + row_values.push_back(coeffs[k]); + if (!is_integer(coeffs[k], tol)) integral = false; + } + row_values.push_back(folded_bound); + + double s = 1.0; + if (!integral) { + s = find_scaling_rational( + row_values, 1.0 / tol, fj_bin_scale_cap, (double)fj_bin_scale_cap, tol); + if (!std::isfinite(s) || s <= 0.0) return false; + } + + coef_t row_cmax = 1; + double row_abs_sum = 0; + for (int32_t k = offsets[r]; k < offsets[r + 1]; ++k) { + const int32_t v = variables[k]; + const double a = s * coeffs[k]; + if (!is_integer(a, tol)) return false; + const long ai = std::lround(a); + for (int32_t bk = 0; bk < nbits[v]; ++bk) { + const int32_t bit = bit_start[v] + bk; + const long scaled = side * ai * std::lround(pb.bit_weight[bit]); + const long abs_a = std::labs(scaled); + // Bounded by magnitude, so cmax below and the negated side both stay representable. + if (abs_a > (long)std::numeric_limits::max()) return false; + pb.variables.push_back(bit); + pb.coefficients.push_back((coef_t)scaled); + + if (abs_a > (long)row_cmax) row_cmax = (coef_t)abs_a; + row_abs_sum += (double)abs_a; + if ((double)abs_a > max_abs_coefficient) max_abs_coefficient = (double)abs_a; + } + } + if (row_abs_sum > (double)(INT32_MAX / 2)) return false; + + const double scaled_bound = side * s * folded_bound; + if (!is_integer(scaled_bound, tol)) return false; + const double bound = std::round(scaled_bound); + if (!fj_bin_in_int32(bound)) return false; + // A bit assignment can drive lhs anywhere in [-row_abs_sum, row_abs_sum]. + if (!fj_bin_in_int32(bound - row_abs_sum) || !fj_bin_in_int32(bound + row_abs_sum)) return false; + + pb.offsets.push_back((int32_t)pb.variables.size()); + pb.bound.push_back((int32_t)bound); + pb.cmax.push_back(row_cmax); + incoming_weight.push_back(weight); + return true; + }; + + for (int32_t r = 0; r < m; ++r) { + const double lb = cstr_lb[r]; + const double ub = cstr_ub[r]; + if (std::isfinite(lb) && !emit(r, lb, -1, left_w[r])) return false; + if (std::isfinite(ub) && !emit(r, ub, 1, right_w[r])) return false; + } + pb.n_constraints = (int32_t)pb.bound.size(); + if (pb.n_constraints <= 0) return false; + pb.nnz = (int32_t)pb.variables.size(); + + if (max_abs_coefficient <= 127.0) { + coefficient_bits = 8; + } else if (max_abs_coefficient <= 32767.0) { + coefficient_bits = 16; + } else { + return false; + } + + pb.variables.resize(pb.nnz + fj_bin_simd_padding, 0); + pb.coefficients.resize(pb.nnz + fj_bin_simd_padding, (coef_t)0); + + double w_min = std::numeric_limits::infinity(); + for (double w : incoming_weight) { + if (w > 0 && w < w_min) w_min = w; + } + double scale = 1.0; + if (std::isfinite(w_min) && w_min > 0) { + scale = (double)fj_bin_ddfw_init / w_min; + if (scale < 1.0) scale = 1.0; + } + for (double w : incoming_weight) { + int32_t scaled = w > 0 ? (int32_t)std::lround(w * scale) : fj_bin_ddfw_init; + if (scaled < 1) scaled = 1; + pb.initial_weight.push_back(scaled); + } + + pb.reverse_offsets.assign(n_bits + 1, 0); + for (int32_t k = 0; k < pb.nnz; ++k) pb.reverse_offsets[pb.variables[k] + 1]++; + for (int32_t v = 0; v < n_bits; ++v) pb.reverse_offsets[v + 1] += pb.reverse_offsets[v]; + pb.reverse_constraints.resize(pb.nnz); + pb.reverse_coefficients.resize(pb.nnz); + pb.reverse_to_csr.resize(pb.nnz); + pb.incident_row_cmax.resize(pb.nnz); + { + std::vector cursor(pb.reverse_offsets.begin(), pb.reverse_offsets.begin() + n_bits); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t slot = cursor[pb.variables[k]]++; + pb.reverse_constraints[slot] = r; + pb.reverse_coefficients[slot] = pb.coefficients[k]; + pb.reverse_to_csr[slot] = k; + pb.incident_row_cmax[slot] = pb.cmax[r]; + } + } + } + const int32_t rpad = fj_bin_pf_dist > fj_bin_simd_padding ? fj_bin_pf_dist : fj_bin_simd_padding; + pb.reverse_constraints.resize(pb.nnz + rpad, 0); + pb.reverse_coefficients.resize(pb.nnz + rpad, (coef_t)0); + pb.incident_row_cmax.resize(pb.nnz + rpad, (coef_t)1); + + pb.objective.assign(n_bits, 0.0); + pb.objective_vars.clear(); + for (int32_t v = 0; v < n; ++v) { + pb.orig_objective[v] = obj[v]; + if (obj[v] == 0.0) continue; + for (int32_t bk = 0; bk < nbits[v]; ++bk) { + const int32_t bit = bit_start[v] + bk; + pb.objective[bit] = obj[v] * pb.bit_weight[bit]; + if (pb.objective[bit] != 0.0) pb.objective_vars.push_back(bit); + } + } + + // The cardinality census only reads as a count on rows of plain binaries. + pb.n_exchange_vars = 0; + pb.max_card_degree = 0; + return true; +} + +// The integer engine. Feasibility is an exact compare against one bound per row, so there is no +// tolerance arithmetic and no compensated summation anywhere below. +template +struct fj_bin_engine_t { + fj_bin_problem_t pb; + // The only mutable per-row state besides the slack. Everything else the apply path once read + // per row now reaches it at unit stride: bound stayed in pb, where only the rebuild paths need + // it, and cmax went to pb.incident_row_cmax, replicated per incidence. + std::vector row_weight; + + // Per row, bound - lhs: negative exactly when the row is violated, and moved by a flip by exactly + // -reverse_coefficients. The only mutable state the vectorized walk gathers. + std::vector row_slack; + + std::vector assign; + std::vector best_assign; + std::shared_ptr> shared_incumbent; + // Staging for an adopted assignment, which arrives as f_t. Sized only when sharing is on. + std::vector adopt_buffer; + std::vector seed_assign; // restart target + std::vector assign_i32; // gather mirror for the SIMD patch (Batch B) + + std::vector best_infeasible_assign; + int64_t best_infeasible_severity{std::numeric_limits::max()}; + int64_t checkpoint_severity{std::numeric_limits::max()}; + int32_t iters_since_infeasible_improve{0}; + int32_t restores_since_improvement{0}; + + std::vector var_score; // live feasibility score of flipping each variable + std::vector nnz_score_delta; // per CSR nnz: last score delta of variables[k] in its row + + // Objective half of the move score, held live so a weighted global scan can stay vectorized. + // Its support is pb.objective_vars, so entries outside that set are zero for the whole solve. + std::vector obj_base_score; + std::vector combined_score; + // Objective weight obj_base_score was built for; -1 marks it stale. + int32_t obj_base_weight{-1}; + + fj_bin_tabu_t tabu; + + std::vector is_violated; + std::vector violated_list; + std::vector vpos; + // Duplicate guard for find_move_in_rows, its only reader. Zero everywhere outside that function, + // which clears what it set before returning. + std::vector var_bitmap; + + // One generator advanced across the whole search, rather than one re-seeded per call site per + // iteration. Re-seeding from `seed + iters` gave every call site in an iteration the identical + // stream, and a 624-word Mersenne state was being built and discarded on every move selection. + raft::random::PCGenerator rng{0, 0, 0}; + std::vector sample_buf; // move-selection row sample, reused to keep the loop allocation-free + + int32_t objective_weight{0}; + int32_t seed_objective_weight{0}; + // Feasible local minima since best_objective last moved, and the value it was last seen at. + int32_t iterations_at_same_objective{0}; + double last_best_objective{std::numeric_limits::infinity()}; + // Mean absolute nonzero objective coefficient; the unit of the objective score term. + double obj_magnitude{1.0}; + double incumbent_objective{0}; + // sum(obj_j * L_j), folded out of the encoded objective and carried here so both tracked + // objectives hold the model's own value. Zero on the all-binary path. + double objective_offset{0}; + double best_objective{std::numeric_limits::infinity()}; + int32_t max_weight{1}; + bool feasible_found{false}; + + int32_t iters{0}; + // Iterations since best_objective last moved. Counts iterations, unlike + // iterations_at_same_objective, so it is comparable against perturb_interval. + int32_t iters_since_best{0}; + int32_t last_restart_iter{0}; + int32_t last_kick_iter{0}; + int64_t nnz_touched{0}; + + // Denominator for the ops-per-nnz roofline: nonzeros the row kernel actually processes, and the + // rows walked to find them. Unlike nnz_touched these are not mixed with the full-matrix rebuilds. + int64_t nnz_patched{0}; + int64_t rows_walked{0}; + + int64_t n_checkpoint_restores{0}; + int64_t n_checkpoint_snapshots{0}; + int32_t max_restores_since_improvement{0}; + + // Tile width for the argmax sweep, in variables. Set at init from fj_bin_argmax_tile(). + int32_t argmax_tile{fj_bin_argmax_tile_target}; + + // Settings read at solve entry, where the climber carries populated values. + int32_t seed{0}; + int32_t tabu_tenure_min{3}; + int32_t tabu_tenure_max{13}; + int32_t perturb_interval{100}; + int32_t mtm_viol_samples{25}; + int32_t mtm_sat_samples{15}; + bool enable_infeasible_repair{false}; + int32_t last_repair_iter{0}; + int32_t infeasible_restart_window{300}; + int32_t infeasible_restart_max_streak{20}; + double infeasible_restart_degrade_ratio{1.15}; + double infeasible_checkpoint_refresh_ratio{0.99}; + double breakthrough_margin{1e-4}; + + int32_t max_aggregate_base{0}; + int32_t max_aggregate_bonus{0}; + + int coefficient_bits() const { return 8 * (int)sizeof(coef_t); } + + // Largest per-variable aggregate base and bonus under the final weights and assignment, in raw + // int32. The packed representation is only order-preserving while these stay inside their + // limits, and weights grow without a cap, so this is the reading that says whether the packing + // survived the run. + // Independent audit of the incumbent at end of solve. Recomputes every row's lhs and the objective + // from best_assign alone, trusting nothing the incremental path maintained: not the live lhs, not + // violated_list, not the running incumbent_objective. Accumulates in int64 so an int32 lhs + // overflow the eligibility scan was supposed to preclude would show up here rather than wrap + // silently. Runs once per solve, so its cost is not on any hot path. + void verify_incumbent(fj_cpu_climber_t& climber) const + { + if (!feasible_found) return; + + int32_t n_violated = 0; + int64_t worst = 0; + bool lhs_overflow = false; + for (int32_t r = 0; r < pb.n_constraints; ++r) { + int64_t lhs = 0; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + lhs += (int64_t)pb.coefficients[k] * (int64_t)best_assign[pb.variables[k]]; + } + if (lhs < INT32_MIN || lhs > INT32_MAX) lhs_overflow = true; + const int64_t slack = (int64_t)pb.bound[r] - lhs; + if (slack < 0) { + ++n_violated; + if (-slack > worst) worst = -slack; + } + } + + double objective = objective_offset; + for (int32_t v = 0; v < pb.n_variables; ++v) objective += pb.objective[v] * (double)best_assign[v]; + const double drift = std::fabs(objective - best_objective); + + if (n_violated != 0 || lhs_overflow || drift > 1e-6) { + CUOPT_LOG_ERROR( + "%sCPUFJ[bin%d] incumbent audit FAILED: %d violated rows (worst %lld), lhs overflow %d, " + "objective recomputed %.17g vs tracked %.17g (drift %g)", + climber.log_prefix.c_str(), + coefficient_bits(), + n_violated, + (long long)worst, + (int)lhs_overflow, + objective, + best_objective, + drift); + } else { + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] incumbent audit ok: feasible, objective %.17g (drift %g)", + climber.log_prefix.c_str(), + coefficient_bits(), + objective, + drift); + } + } + + void compute_saturation() + { + int32_t peak_base = 0, peak_bonus = 0; + for (int32_t v = 0; v < pb.n_variables; ++v) { + const int8_t flip = (int8_t)(1 - 2 * assign[v]); + int32_t agg_base = 0, agg_bonus = 0; + for (int32_t i = pb.reverse_offsets[v]; i < pb.reverse_offsets[v + 1]; ++i) { + const int32_t r = pb.reverse_constraints[i]; + const int32_t os = row_slack[r]; + const int32_t ns = os - (int32_t)pb.reverse_coefficients[i] * flip; + int32_t base = 0, bonus = 0; + fj_bin_score_delta_parts(os, ns, row_weight[r], base, bonus); + agg_base += base; + agg_bonus += bonus; + } + const int32_t abs_base = agg_base < 0 ? -agg_base : agg_base; + const int32_t abs_bonus = agg_bonus < 0 ? -agg_bonus : agg_bonus; + if (abs_base > peak_base) peak_base = abs_base; + if (abs_bonus > peak_bonus) peak_bonus = abs_bonus; + } + max_aggregate_base = peak_base; + max_aggregate_bonus = peak_bonus; + } + + void set_violated(int32_t r) + { + if (!is_violated[r]) { + is_violated[r] = 1; + vpos[r] = (int32_t)violated_list.size(); + violated_list.push_back(r); + } + } + + void set_satisfied(int32_t r) + { + if (is_violated[r]) { + is_violated[r] = 0; + const int32_t p = vpos[r]; + const int32_t last = violated_list.back(); + violated_list[p] = last; + vpos[last] = p; + violated_list.pop_back(); + vpos[r] = -1; + } + } + + void rebuild_scores() + { + std::fill(var_score.begin(), var_score.end(), 0); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + const int32_t weight = row_weight[r]; + const int32_t os = row_slack[r]; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t v = pb.variables[k]; + const int32_t flip = 1 - 2 * assign[v]; + const int32_t ns = os - (int32_t)pb.coefficients[k] * flip; + const int64_t p = fj_bin_packed_score_delta(os, ns, weight); + nnz_score_delta[k] = p; + var_score[v] += p; + } + } + nnz_touched += pb.nnz; + } + + void recompute_slack() + { + violated_list.clear(); + std::fill(is_violated.begin(), is_violated.end(), (uint8_t)0); + for (int32_t r = 0; r < pb.n_constraints; ++r) { + int32_t lhs = 0; + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) + lhs += (int32_t)pb.coefficients[k] * assign[pb.variables[k]]; + const int32_t slack = pb.bound[r] - lhs; + row_slack[r] = slack; + if (slack < 0) set_violated(r); + } + incumbent_objective = objective_offset; + for (int32_t v = 0; v < pb.n_variables; ++v) incumbent_objective += pb.objective[v] * assign[v]; + nnz_touched += pb.nnz; + rebuild_scores(); + // Every caller of this reached it by replacing the assignment wholesale, so the cached + // per-variable flip directions no longer describe it. + obj_base_weight = -1; + } + + // Base field of the objective term: the weight, signed by the direction of the gain and scaled by + // how large that gain is against the model's typical coefficient. Depends only on the variable's + // own value and the weight, which is what lets a global scan cache it. + int64_t objective_base(int32_t v, int8_t delta) const + { + const double obj_diff = pb.objective[v] * delta; + if (obj_diff == 0) return 0; + cuopt_assert(obj_magnitude > 0, "objective magnitude unit must be positive"); + const double rel = std::fabs(obj_diff) / obj_magnitude; + const double mult = + rel < fj_obj_mult_min ? fj_obj_mult_min : (rel > fj_obj_mult_max ? fj_obj_mult_max : rel); + const double raw = objective_weight * mult; + cuopt_assert(fj_bin_in_int32(raw), "scaled objective weight out of int32 range"); + const int32_t scaled = (int32_t)std::lround(raw); + return (int64_t)(obj_diff < 0 ? scaled : -scaled) * fj_bin_score_k; + } + + int64_t objective_terms(int32_t v, int8_t delta) const + { + const double obj_diff = pb.objective[v] * delta; + int32_t bonus = 0; + const bool old_better = incumbent_objective < best_objective; + const bool new_better = incumbent_objective + obj_diff < best_objective; + if (!old_better && new_better) { + bonus += objective_weight; + } else if (old_better && !new_better) { + bonus -= objective_weight; + } + return objective_base(v, delta) + bonus; + } + + int64_t flip_objective_base(int32_t v) const + { + return objective_base(v, (int8_t)(1 - 2 * assign[v])); + } + + // Only the objective variables are written: the rest of the array is zero from init onwards. + void ensure_objective_base() + { + if (obj_base_weight == objective_weight) return; + for (int32_t v : pb.objective_vars) obj_base_score[v] = flip_objective_base(v); + obj_base_weight = objective_weight; + } + + int64_t full_score(int32_t v, int8_t delta) const + { + if (objective_weight == 0) return var_score[v]; + return var_score[v] + objective_terms(v, delta); + } + + bool tabu_blocked(int32_t v, bool localmin) const { return tabu.blocked(v, iters, localmin); } + + void apply_move(int32_t var, int8_t delta, fj_cpu_climber_t& climber) + { + const int8_t new_val = (int8_t)(assign[var] + delta); + const int8_t new_flip = (int8_t)(1 - 2 * new_val); + const int32_t ob = pb.reverse_offsets[var], oe = pb.reverse_offsets[var + 1]; + int64_t own_score = 0; + + // The tail writes a score delta through int32_t* and calls out to the patch, either of which may + // alias a vector's internal pointer as far as the compiler can prove. Without these locals it + // reloads every base pointer below out of `this` on each visit. + int32_t* const weight_p = row_weight.data(); + int32_t* const slack_p = row_slack.data(); + const int32_t* const rcon_p = pb.reverse_constraints.data(); + const coef_t* const skv_p = pb.reverse_coefficients.data(); + const coef_t* const rcmax_p = pb.incident_row_cmax.data(); + const int32_t* const rcsr_p = pb.reverse_to_csr.data(); + const int32_t* const offsets_p = pb.offsets.data(); + const int32_t* const vars_p = pb.variables.data(); + const coef_t* const coefs_p = pb.coefficients.data(); + int64_t* const var_score_p = var_score.data(); + int64_t* const nnz_delta_p = nnz_score_delta.data(); + const int32_t* const assign_p = assign_i32.data(); + + // Everything a visit still needs once its slack has been advanced. Shared by the two arms below + // so the walk's shape is the only thing that differs between them. + auto finish = [&](int32_t ii) { + const int32_t r = rcon_p[ii]; + const int32_t weight = weight_p[r]; + const int32_t skv = (int32_t)skv_p[ii]; + const int32_t new_slack = slack_p[r]; + const int32_t old_slack = new_slack + skv * delta; + + // A row can only cross its boundary if the flip moves it by at least the distance to it, so + // every transition is inside this list and none was lost with the rows the walk absorbed. + if (new_slack < 0 && old_slack >= 0) { + set_violated(r); + } else if (new_slack >= 0 && old_slack < 0) { + set_satisfied(r); + } + + // The mirror of the walk's deep_sat test. Kept here rather than there because it fires on + // 0.02% of visits and guards the widest rows in the matrix: measured, moving it into the + // vector loop costs more in the 85% case than it saves in the 0.02% one. + const int32_t margin = (int32_t)rcmax_p[ii]; + if (!(old_slack < -margin && new_slack < -margin)) { + const int32_t kb = offsets_p[r], ke = offsets_p[r + 1]; + // TODO: check that this may not cause AVX512 powerdown overheads if the AVX2 row/AVX512 row ratio is unbalanced + fj_bin_patch_row(vars_p, + coefs_p, + kb, + ke, + var_score_p, + nnz_delta_p, + assign_p, + weight, + new_slack, + var); + nnz_touched += ke - kb; + nnz_patched += ke - kb; + } + + // The flipped variable's own score delta. Zero on the rows the walk absorbed -- deeply + // satisfied both ways -- and already stored as zero there. + const int64_t pv = fj_bin_packed_score_delta(new_slack, new_slack - skv * new_flip, weight); + own_score += pv; + nnz_delta_p[rcsr_p[ii]] = pv; + }; + + // A tile at a time: the kernel advances every slack in the tile and reports back only the visits + // that left the row within reach of its boundary, which on supportcase22 is 15.1% of them. The + // buffer is a stack array rather than one sized to the widest reverse degree because the tail + // runs between tiles, which is also what keeps the patch calls out of the vector loop. + // + // Unconditional: a scalar arm for short ranges was tried and never won. Sweeping the degree + // below which apply_move walked the rows itself, bnatt400 degraded monotonically from 14.43M to + // 14.19M iterations/s as the threshold rose from 0 to 64, and crypt16 and supportcase22 were + // flat. At a median degree of 13 and 7 respectively, one gather still beats that many dependent + // scalar load-modify-stores, because it breaks the dependence chain through row_slack rather + // than following it. + int32_t tile_incidence[fj_bin_walk_tile]; + for (int32_t t0 = ob; t0 < oe; t0 += fj_bin_walk_tile) { + const int32_t t1 = (t0 + fj_bin_walk_tile < oe) ? t0 + fj_bin_walk_tile : oe; + const int32_t n_tail = + fj_bin_walk_rows(slack_p, rcon_p, skv_p, rcmax_p, t0, t1, delta, tile_incidence); + for (int32_t j = 0; j < n_tail; ++j) finish(tile_incidence[j]); + } + nnz_touched += oe - ob; + rows_walked += oe - ob; + + assign[var] = new_val; + assign_i32[var] = new_val; + var_score[var] = own_score; + incumbent_objective += pb.objective[var] * delta; + // Only this variable's flip direction moved, so a live cache needs one entry rewritten. + if (obj_base_weight == objective_weight && pb.objective[var] != 0) + obj_base_score[var] = flip_objective_base(var); + + if (violated_list.empty() && incumbent_objective < best_objective) { + best_objective = incumbent_objective; + best_assign = assign; + feasible_found = true; + iters_since_best = 0; + report_incumbent(climber); + } + + const int32_t tenure = + tabu_tenure_min + (int32_t)(rng.next_u32() % (uint32_t)(tabu_tenure_max - tabu_tenure_min)); + tabu.on_flip(var, iters, tenure); + } + + // Publish a new best into the climber, which owns the reporting contract. + void report_incumbent(fj_cpu_climber_t& climber) + { + auto& h_assign = climber.h_assignment; + auto& h_best = climber.h_best_assignment; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) { + h_assign[v] = (f_t)pb.var_offset[v]; + h_best[v] = (f_t)pb.var_offset[v]; + } + for (int32_t b = 0; b < pb.n_variables; ++b) { + if (!assign[b]) continue; + const int32_t v = pb.bit_owner[b]; + h_assign[v] += (f_t)pb.bit_weight[b]; + h_best[v] += (f_t)pb.bit_weight[b]; + } + } else { + for (int32_t v = 0; v < pb.n_variables; ++v) { + h_assign[v] = (f_t)assign[v]; + h_best[v] = (f_t)assign[v]; + } + } + climber.h_incumbent_objective = (f_t)incumbent_objective; + climber.h_best_objective = (f_t)best_objective; + climber.feasible_found = true; + if (shared_incumbent) { shared_incumbent->publish((f_t)best_objective, h_best); } + // Emitted once per improvement so the benchmark harness can reconstruct the + // incumbent trajectory exactly, rather than sampling it at log_interval. + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] new incumbent: objective %.17g", + climber.log_prefix.c_str(), + coefficient_bits(), + best_objective); + if (climber.improvement_callback) { + const double work_units = climber.work_units_elapsed.load(std::memory_order_acquire); + climber.improvement_callback((f_t)best_objective, h_best, work_units); + } + } + + void reweight_constraint(int32_t r, int32_t new_weight) + { + if (new_weight == row_weight[r]) return; + row_weight[r] = new_weight; + if (new_weight > max_weight) max_weight = new_weight; + // The slack is unchanged here, and no variable is excluded, so skip_var matches no index. + const int32_t kb = pb.offsets[r], ke = pb.offsets[r + 1]; + fj_bin_patch_row(pb.variables.data(), + pb.coefficients.data(), + kb, + ke, + var_score.data(), + nnz_score_delta.data(), + assign_i32.data(), + new_weight, + row_slack[r], + -1); + nnz_touched += ke - kb; + nnz_patched += ke - kb; + } + + // DDFW: every violated row gains weight taken from a satisfied neighbour above the donation + // floor, so total weight is roughly conserved and differentiation stays local to the hard region. + // Unit transfers stop moving the landscape on a long stall, so the amount grows with the stall. + int32_t ddfw_transfer() const + { + if (iters_since_infeasible_improve <= fj_bin_ddfw_escalate_after) return fj_bin_ddfw_transfer; + const int32_t over = iters_since_infeasible_improve - fj_bin_ddfw_escalate_after; + const int32_t steps = over / fj_bin_ddfw_escalate_after + 1; + const int32_t scale = steps < fj_bin_ddfw_escalate_max ? steps : fj_bin_ddfw_escalate_max; + return fj_bin_ddfw_transfer * scale; + } + + void update_weights() + { + const int32_t transfer = ddfw_transfer(); + // Donors must stay above the floor, or weights go negative and every base score inverts. + const int32_t donor_floor = fj_bin_ddfw_init + transfer - 1; + + for (int32_t cf : violated_list) { + reweight_constraint(cf, row_weight[cf] + transfer); + const int32_t vo = pb.offsets[cf], ve = pb.offsets[cf + 1]; + if (ve <= vo) continue; + int32_t best_donor = -1, best_w = donor_floor; + for (int32_t s = 0; s < fj_bin_ddfw_donor_samples; ++s) { + const int32_t v = pb.variables[vo + (int32_t)(rng.next_u32() % (uint32_t)(ve - vo))]; + const int32_t no = pb.reverse_offsets[v], ne = pb.reverse_offsets[v + 1]; + if (ne <= no) continue; + const int32_t d = + pb.reverse_constraints[no + (int32_t)(rng.next_u32() % (uint32_t)(ne - no))]; + if (d != cf && !is_violated[d] && row_weight[d] > best_w) { + best_w = row_weight[d]; + best_donor = d; + } + } + if (best_donor >= 0) { + const int32_t donated = row_weight[best_donor] - transfer; + cuopt_assert(donated >= fj_bin_ddfw_init, "donation broke the weight floor"); + reweight_constraint(best_donor, donated); + } + } + if (violated_list.empty()) { + if (best_objective < last_best_objective) { + iterations_at_same_objective = 0; + last_best_objective = best_objective; + } else { + ++iterations_at_same_objective; + } + objective_weight += objective_weight_increment(); + } + track_infeasible_checkpoint(); + } + + // Stall-escalation for the objective weight, the feasible-region counterpart of ddfw_transfer: + // a lane that keeps reaching local minima without moving its best objective needs more + // objective pressure than one that is still improving. + int32_t objective_weight_increment() const + { + if (iterations_at_same_objective <= fj_bin_obj_stall_after) return 1; + const int32_t steps = + 1 + (iterations_at_same_objective - fj_bin_obj_stall_after) / fj_bin_obj_stall_after; + return steps < fj_bin_obj_escalate_max ? steps : fj_bin_obj_escalate_max; + } + + void reset_infeasible_checkpoint() + { + best_infeasible_assign.clear(); + best_infeasible_severity = std::numeric_limits::max(); + checkpoint_severity = std::numeric_limits::max(); + iters_since_infeasible_improve = 0; + } + + void track_infeasible_checkpoint() + { + if (violated_list.empty()) { + reset_infeasible_checkpoint(); + return; + } + + int64_t severity = 0; + for (int32_t r : violated_list) { + cuopt_assert(row_slack[r] < 0, "row in violated_list is not violated"); + severity -= (int64_t)row_slack[r]; + } + + if (severity < best_infeasible_severity) { + best_infeasible_severity = severity; + iters_since_infeasible_improve = 0; + restores_since_improvement = 0; + if ((double)severity < (double)checkpoint_severity * infeasible_checkpoint_refresh_ratio) { + best_infeasible_assign = assign; + checkpoint_severity = severity; + ++n_checkpoint_snapshots; + } + return; + } + + if (restores_since_improvement >= infeasible_restart_max_streak) return; + if (++iters_since_infeasible_improve < infeasible_restart_window) return; + if ((double)severity <= (double)best_infeasible_severity * infeasible_restart_degrade_ratio) + return; + if (best_infeasible_assign.empty()) return; + + cuopt_assert(checkpoint_severity >= best_infeasible_severity, + "checkpoint cannot beat the best severity seen"); + + assign = best_infeasible_assign; + for (int32_t v = 0; v < pb.n_variables; ++v) + assign_i32[v] = assign[v]; + recompute_slack(); + + ++n_checkpoint_restores; + ++restores_since_improvement; + if (restores_since_improvement > max_restores_since_improvement) + max_restores_since_improvement = restores_since_improvement; + iters_since_infeasible_improve = 0; + } + + // Global argmax over every variable, affordable because var_score is maintained live. While the + // objective weight is zero the full score is exactly var_score; above zero the sweep runs over + // var_score plus the cached objective base. Only the local-minimum path falls to the scalar loop. + std::pair find_move_global(bool localmin) + { + if (!localmin && objective_weight == 0) { + // The sweep reads var_score alone; the handful of tabu variables are held at the invalid + // sentinel across it rather than tested per variable. + int32_t saved_var[fj_bin_tabu_t::ring_size]; + int64_t saved_score[fj_bin_tabu_t::ring_size]; + const int32_t blocked = tabu.block_tabu(iters, var_score.data(), saved_var, saved_score); + + int32_t v = -1; + int64_t s = fj_bin_score_invalid; + fj_bin_argmax(var_score.data(), pb.n_variables, argmax_tile, v, s); + + fj_bin_tabu_t::unblock_tabu(blocked, var_score.data(), saved_var, saved_score); + return {v, s}; + } + + if (!localmin) { + // The breakthrough bonus is deliberately absent from the ranking: it depends on + // incumbent_objective, so no per-variable form of it survives a move, and it occupies the low + // field where it can only separate variables already tied on the base. The winner's score is + // then taken from full_score so the caller sees the true value. + ensure_objective_base(); + int64_t* const comb_p = combined_score.data(); + fj_bin_add_scores(var_score.data(), obj_base_score.data(), pb.n_variables, comb_p); + + int32_t saved_var[fj_bin_tabu_t::ring_size]; + int64_t saved_score[fj_bin_tabu_t::ring_size]; + const int32_t blocked = tabu.block_tabu(iters, comb_p, saved_var, saved_score); + + int32_t v = -1; + int64_t s = fj_bin_score_invalid; + fj_bin_argmax(comb_p, pb.n_variables, argmax_tile, v, s); + + fj_bin_tabu_t::unblock_tabu(blocked, comb_p, saved_var, saved_score); + if (v >= 0) s = full_score(v, (int8_t)(1 - 2 * assign[v])); + return {v, s}; + } + + int32_t best_v = -1; + int64_t best_s = fj_bin_score_invalid; + for (int32_t v = 0; v < pb.n_variables; ++v) { + if (tabu_blocked(v, localmin)) continue; + const int64_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + if (s > best_s) { + best_s = s; + best_v = v; + } + } + return {best_v, best_s}; + } + + std::pair find_move_in_rows(const std::vector& target_rows, + bool localmin) + { + int32_t best_v = -1; + int64_t best_s = fj_bin_score_invalid; + for (int32_t r : target_rows) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) { + const int32_t v = pb.variables[k]; + if (var_bitmap[v]) continue; + var_bitmap[v] = 1; + if (tabu_blocked(v, localmin)) continue; + const int64_t s = full_score(v, (int8_t)(1 - 2 * assign[v])); + if (s > best_s) { + best_s = s; + best_v = v; + } + } + } + // Restore the all-zero invariant by revisiting only what was set: the sampled rows hold a few + // dozen variables against n in the thousands, so this is far cheaper than clearing the array. + for (int32_t r : target_rows) { + for (int32_t k = pb.offsets[r]; k < pb.offsets[r + 1]; ++k) var_bitmap[pb.variables[k]] = 0; + } + return {best_v, best_s}; + } + + std::pair find_move_violated(int32_t sample_size, bool localmin) + { + // Draw the rows directly instead of reservoir-sampling the violated list: `std::sample` is + // linear in the population, so it walked every violated row to keep a handful. Sampling with + // replacement is what `find_move_satisfied` already does, and `find_move_in_rows` deduplicates + // variables through `var_bitmap`, so a repeated row costs a bitmap sweep and no scoring. + const int32_t n = (int32_t)violated_list.size(); + const std::vector* sampled = &violated_list; + if (n > sample_size) { + sample_buf.clear(); + for (int32_t i = 0; i < sample_size; ++i) { + sample_buf.push_back(violated_list[rng.next_u32() % (uint32_t)n]); + } + sampled = &sample_buf; + } + auto move = find_move_in_rows(*sampled, localmin); + + // Breakthrough moves: once a feasible solution exists, allow objective-driven jumps. + if (feasible_found && incumbent_objective >= best_objective + breakthrough_margin) { + for (int32_t v : pb.objective_vars) { + const double step = (best_objective - incumbent_objective) / pb.objective[v]; + double target = pb.objective[v] > 0 ? std::floor(assign[v] + step) + : std::ceil(assign[v] + step); + if (target < 0) target = 0; + if (target > 1) target = 1; + if ((int8_t)target == assign[v]) continue; + if (tabu_blocked(v, false)) continue; + const int64_t s = full_score(v, (int8_t)((int8_t)target - assign[v])); + if (s > move.second) move = {v, s}; + } + } + return move; + } + + std::pair find_move_satisfied(int32_t sample_size) + { + sample_buf.clear(); + for (int32_t tries = 0; (int32_t)sample_buf.size() < sample_size && tries < sample_size * 8; + ++tries) { + const int32_t r = (int32_t)(rng.next_u32() % (uint32_t)pb.n_constraints); + if (!is_violated[r]) sample_buf.push_back(r); + } + return find_move_in_rows(sample_buf, false); + } + + // True when flipping both variables leaves every row they touch satisfied. Both reverse ranges + // are row-ascending, so shared rows are handled jointly by merging them. + bool paired_flip_keeps_feasible( + int32_t var1, int8_t delta1, int32_t var2, int8_t delta2) const + { + int32_t i = pb.reverse_offsets[var1], ie = pb.reverse_offsets[var1 + 1]; + int32_t j = pb.reverse_offsets[var2], je = pb.reverse_offsets[var2 + 1]; + + while (i < ie || j < je) { + const int32_t r1 = i < ie ? pb.reverse_constraints[i] : INT32_MAX; + const int32_t r2 = j < je ? pb.reverse_constraints[j] : INT32_MAX; + const int32_t r = r1 < r2 ? r1 : r2; + + int32_t change = 0; + if (r1 == r) change += (int32_t)pb.reverse_coefficients[i++] * delta1; + if (r2 == r) change += (int32_t)pb.reverse_coefficients[j++] * delta2; + if (row_slack[r] - change < 0) return false; + } + return true; + } + + // Net change in the violated-row count from flipping both variables. Positive is an improvement. + // Both reverse ranges are row-ascending, so shared rows are counted once with their joint delta. + int32_t paired_flip_violation_delta(int32_t var1, + int8_t delta1, + int32_t var2, + int8_t delta2) const + { + int32_t i = pb.reverse_offsets[var1], ie = pb.reverse_offsets[var1 + 1]; + int32_t j = pb.reverse_offsets[var2], je = pb.reverse_offsets[var2 + 1]; + int32_t net = 0; + + while (i < ie || j < je) { + const int32_t r1 = i < ie ? pb.reverse_constraints[i] : INT32_MAX; + const int32_t r2 = j < je ? pb.reverse_constraints[j] : INT32_MAX; + const int32_t r = r1 < r2 ? r1 : r2; + + int32_t change = 0; + if (r1 == r) change += (int32_t)pb.reverse_coefficients[i++] * delta1; + if (r2 == r) change += (int32_t)pb.reverse_coefficients[j++] * delta2; + + const bool was_violated = row_slack[r] < 0; + const bool now_violated = row_slack[r] - change < 0; + if (was_violated && !now_violated) + ++net; + else if (!was_violated && now_violated) + --net; + } + return net; + } + + // Draws a few violated rows and searches their members for a joint flip that strictly reduces the + // violated-row count. The single-flip path cannot see these: each half may be neutral or worsening + // on its own. Rate-limited by the caller because the pair scan is quadratic in the pool. + std::pair find_infeasible_pair_repair() + { + const std::pair none{-1, -1}; + if (violated_list.empty()) return none; + + sample_buf.clear(); + const int32_t n_viol = (int32_t)violated_list.size(); + const int32_t n_rows = n_viol < fj_bin_repair_max_rows ? n_viol : fj_bin_repair_max_rows; + for (int32_t t = 0; t < n_rows; ++t) + sample_buf.push_back(violated_list[rng.next_u32() % (uint32_t)n_viol]); + + int32_t pool[fj_bin_repair_max_vars]; + int32_t n_pool = 0; + for (int32_t r : sample_buf) { + const int32_t begin = pb.offsets[r]; + const int32_t width = pb.offsets[r + 1] - begin; + if (width == 0) continue; + + // A random cyclic start rather than the CSR prefix. At a repeated local minimum the prefix + // makes the neighbourhood deterministic and leaves the tail of a wide covering row permanently + // invisible, at the same pool size and cost. + const int32_t start = (int32_t)(rng.next_u32() % (uint32_t)width); + for (int32_t q = 0; q < width && n_pool < fj_bin_repair_max_vars; ++q) { + const int32_t v = pb.variables[begin + (start + q) % width]; + bool dup = false; + for (int32_t p = 0; p < n_pool && !dup; ++p) + dup = pool[p] == v; + if (!dup) pool[n_pool++] = v; + } + } + + std::pair best_pair = none; + int32_t best_net = 0; + for (int32_t a = 0; a < n_pool; ++a) { + const int32_t v1 = pool[a]; + if (tabu_blocked(v1, false)) continue; + const int8_t delta1 = (int8_t)(1 - 2 * assign[v1]); + + for (int32_t b = a + 1; b < n_pool; ++b) { + const int32_t v2 = pool[b]; + if (tabu_blocked(v2, false)) continue; + const int8_t delta2 = (int8_t)(1 - 2 * assign[v2]); + const int32_t net = paired_flip_violation_delta(v1, delta1, v2, delta2); + if (net > best_net) { + best_net = net; + best_pair = {v1, v2}; + } + } + } + cuopt_assert(best_pair.first < 0 || best_net > 0, "accepted a repair that gains no row"); + return best_pair; + } + + std::pair, int64_t> find_lift_2opt_move() + { + cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); + + std::pair best_pair = {-1, -1}; + int64_t best_s = 0; + double best_improvement = 0; + if (pb.objective_vars.empty()) return {best_pair, best_s}; + + const uint32_t n_obj = (uint32_t)pb.objective_vars.size(); + const int32_t n_draws = n_obj < (uint32_t)fj_bin_2opt_candidates ? (int32_t)n_obj + : fj_bin_2opt_candidates; + + for (int32_t t = 0; t < n_draws; ++t) { + const int32_t var1 = pb.objective_vars[rng.next_u32() % n_obj]; + const int8_t delta1 = (int8_t)(1 - 2 * assign[var1]); + if ((double)delta1 * pb.objective[var1] >= 0) continue; + if (tabu_blocked(var1, false)) continue; + + // Only pairs are useful here: a flip breaking nothing is already the single-flip lift's job, + // and one breaking several rows cannot be repaired by a single companion. + int32_t broken = -1; + bool multiple = false; + for (int32_t i = pb.reverse_offsets[var1]; i < pb.reverse_offsets[var1 + 1] && !multiple; + ++i) { + const int32_t r = pb.reverse_constraints[i]; + if (row_slack[r] - (int32_t)pb.reverse_coefficients[i] * delta1 < 0) { + if (broken >= 0) + multiple = true; + else + broken = r; + } + } + if (multiple || broken < 0) continue; + + for (int32_t k = pb.offsets[broken]; k < pb.offsets[broken + 1]; ++k) { + const int32_t var2 = pb.variables[k]; + if (var2 == var1) continue; + + const int8_t delta2 = (int8_t)(1 - 2 * assign[var2]); + const double combined = (double)delta1 * pb.objective[var1] + + (double)delta2 * pb.objective[var2]; + if (combined >= 0) continue; + if (tabu_blocked(var2, false)) continue; + if (!paired_flip_keeps_feasible(var1, delta1, var2, delta2)) continue; + + // Both lift operators rank on the objective gain in its own units: the packed score counts + // weights, and this engine requires an integral matrix but not integral objective terms. + const double improvement = -combined; + if (improvement > best_improvement) { + best_improvement = improvement; + best_s = 1; // sign only, never compared against another operator's score + best_pair = {var1, var2}; + } + } + } + cuopt_assert((best_pair.first < 0) == (best_improvement <= 0), + "pair and score must agree on whether a move was found"); + return {best_pair, best_s}; + } + + std::pair find_lift_move() const + { + cuopt_assert(violated_list.empty(), "lift moves require a feasible incumbent"); + + int32_t best_v = -1; + int64_t best_s = 0; + double best_improvement = 0; + for (int32_t v : pb.objective_vars) { + const int8_t delta = (int8_t)(1 - 2 * assign[v]); + if ((double)delta * pb.objective[v] >= 0) continue; + if (tabu_blocked(v, false)) continue; + // Base field is zero iff the flip breaks no row; K/2 splits it while |bonus| < 2^31. + if (var_score[v] <= -(fj_bin_score_k / 2)) continue; + const double improvement = -pb.objective[v] * (double)delta; + if (improvement > best_improvement) { + best_improvement = improvement; + best_s = 1; + best_v = v; + } + } + cuopt_assert((best_v < 0) == (best_improvement <= 0), + "move and score must agree on whether a move was found"); + return {best_v, best_s}; + } + + // Flips a few variables drawn from violated rows, to leave a basin the weights cannot escape. + void infeasible_region_kick() + { + const int32_t n_viol = (int32_t)violated_list.size(); + cuopt_assert(n_viol > 0, "kick requires a violated row"); + + int32_t flipped[fj_bin_kick_rows * fj_bin_kick_vars_per_row]; + int32_t n_flipped = 0; + + for (int32_t i = 0; i < fj_bin_kick_rows; ++i) { + const int32_t r = violated_list[rng.next_u32() % (uint32_t)n_viol]; + const int32_t row_begin = pb.offsets[r]; + const int32_t row_end = pb.offsets[r + 1]; + if (row_begin >= row_end) continue; + + for (int32_t j = 0; j < fj_bin_kick_vars_per_row; ++j) { + const int32_t k = row_begin + (int32_t)(rng.next_u32() % (uint32_t)(row_end - row_begin)); + const int32_t v = pb.variables[k]; + + bool already = false; + for (int32_t f = 0; f < n_flipped && !already; ++f) + already = flipped[f] == v; + if (already) continue; + + cuopt_assert(n_flipped < fj_bin_kick_rows * fj_bin_kick_vars_per_row, "flip list overflow"); + flipped[n_flipped++] = v; + assign[v] = (int8_t)(1 - assign[v]); + assign_i32[v] = assign[v]; + } + } + recompute_slack(); + } + + void perturb() + { + if (pb.objective_vars.empty()) return; + if (feasible_found) { + cuopt_assert((int32_t)best_assign.size() == pb.n_variables, "incumbent size mismatch"); + assign = best_assign; + // The shared buffer holds decoded integers, so the flat 0/1 read below only lines up when + // engine variables are the model's own variables. + if (!pb.encoded && shared_incumbent && + shared_incumbent->adopt((f_t)best_objective, adopt_buffer)) { + for (int32_t v = 0; v < pb.n_variables; ++v) + assign[v] = (int8_t)(adopt_buffer[v] >= 0.5 ? 1 : 0); + } + for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; + } + const uint32_t n = (uint32_t)pb.objective_vars.size(); + for (int i = 0; i < 2; ++i) { + const int32_t v = pb.objective_vars[rng.next_u32() % n]; + assign[v] = (int8_t)(rng.next_u32() & 1u); + assign_i32[v] = assign[v]; + } + recompute_slack(); + } + + // Restart returns the assignment to the seed the climber was constructed with, leaving the + // recorded best and the global iteration counter intact. + void do_restart() + { + assign = seed_assign; + for (int32_t v = 0; v < pb.n_variables; ++v) assign_i32[v] = assign[v]; + for (int32_t r = 0; r < pb.n_constraints; ++r) row_weight[r] = pb.initial_weight[r]; + max_weight = fj_bin_ddfw_init; + objective_weight = seed_objective_weight; + reset_infeasible_checkpoint(); + tabu.clear(iters); + recompute_slack(); + last_restart_iter = iters; + // The restarted walk gets a full window before the stall gate can perturb it. + iters_since_best = 0; + } + + void init(fj_cpu_climber_t& climber) + { + const auto& params = climber.settings.parameters; + seed = climber.settings.seed; + rng = raft::random::PCGenerator((uint64_t)seed, 0, 0); + tabu_tenure_min = params.tabu_tenure_min; + tabu_tenure_max = params.tabu_tenure_max; + breakthrough_margin = params.breakthrough_move_epsilon; + perturb_interval = climber.perturb_interval; + mtm_viol_samples = climber.mtm_viol_samples; + mtm_sat_samples = climber.mtm_sat_samples; + enable_infeasible_repair = climber.enable_infeasible_repair && + pb.n_exchange_vars >= fj_bin_repair_min_exchange_vars && + pb.max_card_degree <= fj_bin_repair_max_card_degree; + last_repair_iter = 0; + + infeasible_restart_window = climber.infeasible_restart_window; + infeasible_restart_max_streak = climber.infeasible_restart_max_streak; + infeasible_restart_degrade_ratio = (double)climber.infeasible_restart_degrade_ratio; + infeasible_checkpoint_refresh_ratio = (double)climber.infeasible_checkpoint_refresh_ratio; + cuopt_assert(infeasible_restart_window > 0, "invalid infeasible restart window"); + cuopt_assert(infeasible_restart_max_streak > 0, "invalid infeasible restart streak cap"); + cuopt_assert(infeasible_restart_degrade_ratio >= 1.0, "degrade ratio should be at least one"); + cuopt_assert( + infeasible_checkpoint_refresh_ratio > 0.0 && infeasible_checkpoint_refresh_ratio <= 1.0, + "checkpoint refresh ratio should be in (0, 1]"); + + if (tabu_tenure_max <= tabu_tenure_min) tabu_tenure_max = tabu_tenure_min + 1; + + // The tabu ring is indexed by iteration modulo its size, so a slot is reused after ring_size + // iterations. A tenure that long would be overwritten while the variable is still tabu, and the + // argmax would stop excluding it. Clamped as well as asserted: release builds compile the assert + // out, and silently dropping tabu entries is worse than a shorter tenure. + cuopt_assert(tabu_tenure_max <= fj_bin_tabu_t::max_tenure, + "tabu tenure exceeds the tabu ring, live entries would be evicted"); + if (tabu_tenure_max > fj_bin_tabu_t::max_tenure) tabu_tenure_max = fj_bin_tabu_t::max_tenure; + + const int32_t n = pb.n_variables, m = pb.n_constraints; + const auto& h_assign = climber.h_assignment; + assign.assign(n, 0); + if (pb.encoded) { + // Descending weight, so the bit pattern reproduces the start value wherever it is + // representable: with exact closure that is every integer of the domain. + std::vector> bits_of(pb.n_original); + for (int32_t b = 0; b < n; ++b) bits_of[pb.bit_owner[b]].push_back(b); + for (int32_t v = 0; v < pb.n_original; ++v) { + long residual = std::lround((double)h_assign[v] - pb.var_offset[v]); + if (residual < 0) residual = 0; + auto& bits = bits_of[v]; + std::sort(bits.begin(), bits.end(), [&](int32_t a, int32_t b) { + return pb.bit_weight[a] > pb.bit_weight[b]; + }); + for (int32_t b : bits) { + const long w = std::lround(pb.bit_weight[b]); + if (w <= residual) { + assign[b] = 1; + residual -= w; + } + } + cuopt_assert(residual == 0, "greedy bit encode left the start value unrepresented"); + } + } else { + for (int32_t v = 0; v < n; ++v) { + const double val = (double)h_assign[v]; + assign[v] = (int8_t)(val >= 0.5 ? 1 : 0); + } + } + seed_assign = assign; + best_assign = assign; + shared_incumbent = climber.shared_incumbent; + if (shared_incumbent) adopt_buffer.assign(n, 0); + reset_infeasible_checkpoint(); + assign_i32.assign(n, 0); + for (int32_t v = 0; v < n; ++v) assign_i32[v] = assign[v]; + + row_weight.assign(pb.initial_weight.begin(), pb.initial_weight.end()); + row_slack.assign(m, 0); + + var_score.assign(n, 0); + nnz_score_delta.assign(pb.nnz + fj_bin_simd_padding, 0); + // Zeroed once: ensure_objective_base only ever rewrites the objective variables. + obj_base_score.assign(n, 0); + combined_score.assign(n, 0); + obj_base_weight = -1; + tabu.resize(n); + is_violated.assign(m, 0); + vpos.assign(m, -1); + violated_list.clear(); + var_bitmap.assign(n, 0); + + const int32_t seeded_weight = (int32_t)std::lround(climber.h_objective_weight); + cuopt_assert(seeded_weight >= 0, "objective weight should be positive or zero"); + + double abs_obj_sum = 0; + for (int32_t v : pb.objective_vars) abs_obj_sum += std::fabs(pb.objective[v]); + obj_magnitude = abs_obj_sum > 0 ? abs_obj_sum / (double)pb.objective_vars.size() : 1.0; + cuopt_assert(std::isfinite(obj_magnitude) && obj_magnitude > 0, + "objective magnitude unit must be finite and positive"); + + objective_offset = 0; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) + objective_offset += pb.orig_objective[v] * pb.var_offset[v]; + } + + argmax_tile = fj_bin_argmax_tile(); + objective_weight = seeded_weight > 0 ? seeded_weight : 0; + seed_objective_weight = objective_weight; + max_weight = fj_bin_ddfw_init; + incumbent_objective = 0; + best_objective = std::numeric_limits::infinity(); + last_best_objective = std::numeric_limits::infinity(); + iterations_at_same_objective = 0; + feasible_found = false; + iters = 0; + iters_since_best = 0; + last_restart_iter = 0; + last_kick_iter = 0; + recompute_slack(); + } + + void solve(fj_cpu_climber_t& climber, f_t time_limit, double work_unit_limit) + { + init(climber); + + const auto loop_start = std::chrono::high_resolution_clock::now(); + const auto limit = + std::chrono::milliseconds((int64_t)std::floor((double)time_limit * 1000.0)); + const bool bounded_time = std::isfinite((double)time_limit); + + while (!climber.halted && !climber.preemption_flag.load()) { + if (bounded_time && std::chrono::high_resolution_clock::now() - loop_start > limit) break; + if (iters >= climber.settings.iteration_limit) break; + if (iters - last_restart_iter >= fj_bin_restart_period) do_restart(); + tabu.maybe_rebase(iters); + + int32_t move_var = -1; + int64_t score = fj_bin_score_invalid; + std::pair pair2 = {-1, -1}; + if (violated_list.empty()) { + std::tie(move_var, score) = find_lift_move(); + // Pairs are only reachable once no single improving flip preserves feasibility. + if (score <= 0) { + int64_t pair_score; + std::tie(pair2, pair_score) = find_lift_2opt_move(); + if (pair_score > 0) score = pair_score; + } + } + if (pair2.first < 0 && score <= 0) std::tie(move_var, score) = find_move_global(false); + if (pair2.first < 0 && feasible_found && score <= 0) + std::tie(move_var, score) = find_move_satisfied(mtm_sat_samples); + + bool perturb_now = false; + if (violated_list.empty() && iters_since_best > perturb_interval) { + perturb_now = true; + // Without this the counter stays above the interval and every later iteration perturbs. + iters_since_best = 0; + } + + if (pair2.first >= 0 && !perturb_now) { + apply_move(pair2.first, (int8_t)(1 - 2 * assign[pair2.first]), climber); + apply_move(pair2.second, (int8_t)(1 - 2 * assign[pair2.second]), climber); + } else if (score > 0 && move_var >= 0 && !perturb_now) { + apply_move(move_var, (int8_t)(1 - 2 * assign[move_var]), climber); + } else { + // A pair that reduces the violated count takes precedence over reweighting: the weights + // exist to escape a minimum no move can improve, and this found one that can. + bool repaired = false; + if (enable_infeasible_repair && !violated_list.empty() && + iters - last_repair_iter >= fj_bin_repair_interval) { + last_repair_iter = iters; + const auto repair_pair = find_infeasible_pair_repair(); + if (repair_pair.first >= 0) { + apply_move(repair_pair.first, (int8_t)(1 - 2 * assign[repair_pair.first]), climber); + apply_move(repair_pair.second, (int8_t)(1 - 2 * assign[repair_pair.second]), climber); + repaired = true; + } + } + + if (!repaired) { + update_weights(); + const bool kick_ready = !violated_list.empty() && + iters_since_infeasible_improve >= fj_bin_kick_after && + iters - last_kick_iter >= fj_bin_kick_cooldown && + iters - last_restart_iter >= fj_bin_kick_restart_guard; + if (kick_ready) { + infeasible_region_kick(); + last_kick_iter = iters; + } else if (perturb_now) { + perturb(); + } + std::tie(move_var, score) = find_move_violated(1, true); + const int32_t v = move_var >= 0 ? move_var : 0; + apply_move(v, (int8_t)(1 - 2 * assign[v]), climber); + } + } + + if (iters % climber.log_interval == 0) { + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] iteration: %d, viol: %zu, best: %g, maxw: %d", + climber.log_prefix.c_str(), + coefficient_bits(), + iters, + violated_list.size(), + best_objective, + max_weight); + } + if (iters % climber.diversity_callback_interval == 0 && climber.diversity_callback) { + auto& h_assign = climber.h_assignment; + if (pb.encoded) { + for (int32_t v = 0; v < pb.n_original; ++v) h_assign[v] = (f_t)pb.var_offset[v]; + for (int32_t b = 0; b < pb.n_variables; ++b) + if (assign[b]) h_assign[pb.bit_owner[b]] += (f_t)pb.bit_weight[b]; + } else { + for (int32_t v = 0; v < pb.n_variables; ++v) h_assign[v] = (f_t)assign[v]; + } + climber.diversity_callback((f_t)incumbent_objective, h_assign); + } + + // Work-unit proxy. nnz_touched is cumulative, reproducing the accumulation shape the general + // path gets from its cumulative byte counters. + if (iters % 100 == 0 && iters > 0) { + const double work = (double)nnz_touched * fj_bin_bytes_per_nnz * climber.work_unit_bias / 1e10; + climber.work_units_elapsed.store(work, std::memory_order_release); + if (climber.producer_sync != nullptr) climber.producer_sync->notify_progress(); + if (work >= work_unit_limit) break; + } + + ++iters; + ++iters_since_best; + } + + compute_saturation(); + verify_incumbent(climber); + climber.iterations = (i_t)iters; + CUOPT_LOG_DEBUG( + "%sCPUFJ[bin%d] done: %d iterations, best %g, max weight %d, aggregate base %d/%d, bonus %d/%d", + climber.log_prefix.c_str(), + coefficient_bits(), + iters, + best_objective, + max_weight, + max_aggregate_base, + fj_bin_base_limit, + max_aggregate_bonus, + fj_bin_bonus_limit); + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] work: nnz_patched %lld, rows_walked %lld", + climber.log_prefix.c_str(), + coefficient_bits(), + (long long)nnz_patched, + (long long)rows_walked); + CUOPT_LOG_DEBUG("%sCPUFJ[bin%d] checkpoint: %lld restores, %lld snapshots, max streak %d", + climber.log_prefix.c_str(), + coefficient_bits(), + (long long)n_checkpoint_restores, + (long long)n_checkpoint_snapshots, + max_restores_since_improvement); + } +}; + +template +bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + f_t time_limit, + double work_unit_limit) +{ + // Escape hatch for A/B against the general path on an instance the fast path would take. The two + // paths are meant to search identically, so any divergence is a bug in this one; setting this is + // how that gets bisected without editing the eligibility scan. + static const bool disabled = std::getenv("CUOPT_NO_BINFJ") != nullptr; + if (disabled) return false; + + const fj_bin_scan_t scan = fj_bin_scan(climber); + if (scan.reject != fj_binary_reject_t::none) { + // A non-binary variable is the one rejection the encoding can answer: the model may still be + // all-integer with finite domains. Every other reason fails the encoded model just the same. + if (scan.reject == fj_binary_reject_t::non_binary_var) { + // The width the encoded coefficients need is only known once they are built, so probe with + // int16 and rebuild on int8 for the narrower kernel when that is enough. + fj_bin_engine_t probe; + int bits = 0; + if (fj_bin_encode(climber, probe.pb, bits)) { + if (bits == 8) { + fj_bin_engine_t engine8; + int bits8 = 0; + if (fj_bin_encode(climber, engine8.pb, bits8)) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled (encoded int8): %d bits, %d rows", + climber.log_prefix.c_str(), + engine8.pb.n_variables, + engine8.pb.n_constraints); + engine8.solve(climber, time_limit, work_unit_limit); + return true; + } + } + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path enabled (encoded int16): %d bits, %d rows", + climber.log_prefix.c_str(), + probe.pb.n_variables, + probe.pb.n_constraints); + probe.solve(climber, time_limit, work_unit_limit); + return true; + } + } + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s (row %d, var %d)", + climber.log_prefix.c_str(), + fj_binary_reject_name(scan.reject), + scan.bad_row, + scan.bad_var); + return false; + } + + auto run = [&](auto& engine) -> bool { + if (!fj_bin_narrow(climber, scan, engine.pb)) { + CUOPT_LOG_DEBUG("%sCPUFJ binary fast path declined: %s", + climber.log_prefix.c_str(), + fj_binary_reject_name(fj_binary_reject_t::narrow_check_failed)); + return false; + } + CUOPT_LOG_DEBUG( + "%sCPUFJ binary fast path enabled: int%d coefficients, %d rows after one-sided split", + climber.log_prefix.c_str(), + scan.coefficient_bits, + scan.n_split_constraints); + engine.solve(climber, time_limit, work_unit_limit); + return true; + }; + + if (scan.coefficient_bits == 8) { + fj_bin_engine_t engine; + return run(engine); + } + fj_bin_engine_t engine; + return run(engine); +} + +#if MIP_INSTANTIATE_FLOAT +template bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + float time_limit, + double work_unit_limit); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + double time_limit, + double work_unit_limit); +#endif + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh new file mode 100644 index 0000000000..08005817aa --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary.cuh @@ -0,0 +1,131 @@ +/* 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 + +// The fast path applies to instances whose variables are all binary and whose rows carry integer +// coefficients within int8 or int16 range. On those it runs a SIMD integer engine: exact feasibility +// against a single row bound, a live per-variable score patched through stored per-nnz +// contributions, and a global argmax move selection. + +namespace cuopt::mathematical_optimization::mip { + +template +struct fj_cpu_climber_t; + +enum class fj_binary_reject_t : uint8_t { + none, + empty_problem, + non_binary_var, + fractional_coefficient, + coefficient_out_of_range, + fractional_row_bound, + row_bound_out_of_range, + lhs_headroom, + narrow_check_failed, +}; +const char* fj_binary_reject_name(fj_binary_reject_t reason); + +// Returns true if the fast path ran (eligible and narrowed); false if declined, in which case the caller should take the general path. +// TODO: worth revisiting if the same climber is solved repeatedly to cache the fastpath state +template +bool try_cpufj_binary_solve(fj_cpu_climber_t& climber, + f_t time_limit, + double work_unit_limit); + +// Packed staged score: one int64 holding base * K + bonus, encoding the general path's +// lexicographic (base, bonus) comparison as a single arithmetic one. +// +// The width is what makes the encoding faithful. Both fields aggregate over the rows a variable +// appears in, so each is bounded by max_var_degree * max_weight -- unbounded above at build time, +// since DDFW grows the weights. At 15 bits the bonus field overflowed into the base on real +// instances (chromaticindex1024-7 reaches an aggregate bonus of 122880 against 16384), which +// silently corrupts the ordering the argmax depends on. 32 bits leaves the base free to use the +// whole int32 range before the encoding can break. +constexpr int32_t fj_bin_score_shift = 32; +constexpr int64_t fj_bin_score_k = (int64_t)1 << fj_bin_score_shift; +constexpr int64_t fj_bin_score_invalid = INT64_MIN; + +// Change in one row's weighted score when one variable flips, from the row's signed slack before +// (os) and after (ns) that flip. base is the weighted change in satisfaction; bonus is the +// weighted change in strict slack. When both states are violated the improving direction earns +// half weight, matching excess_improvement_weight of 1/2. +// purpose: implements the scoring delta logic from feasibility_jump.cuh in a form easier to port to SIMD +static inline void fj_bin_score_delta_parts( + int32_t os, int32_t ns, int32_t weight, int32_t& base, int32_t& bonus) +{ + const int32_t osat = os >= 0, nsat = ns >= 0; + const int32_t ost = os > 0, nst = ns > 0; + const int32_t improving = (os < ns) - (ns < os); + base = weight * (nsat - osat) + (1 - osat) * (1 - nsat) * improving * (weight / 2); + bonus = weight * (nst - ost); +} + +static inline int64_t fj_bin_packed_score_delta(int32_t os, int32_t ns, int32_t weight) +{ + int32_t base = 0, bonus = 0; + fj_bin_score_delta_parts(os, ns, weight, base, bonus); + return (int64_t)base * fj_bin_score_k + bonus; +} + +// Padding margin to prevent faults on tail SIMD loads +constexpr int32_t fj_bin_simd_padding = 256; + +// Patch every variable of one row against the row's current signed slack. The +// move case passes the post-move slack and the flipped variable's index; the reweight case passes +// the unchanged slack and -1, which matches no variable index. +template +void fj_bin_patch_row(const int32_t* variables, + const coef_t* coefficients, + int32_t kb, + int32_t ke, + int64_t* var_score, + int64_t* nnz_score_delta, + const int32_t* assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var); + +constexpr int32_t fj_bin_walk_tile = 256; + +// Advance every row incident to one flipped variable within apply_move, and report which of those visits the caller +// must finish by hand (e.g. if the row needs patching) +// +// For every incidence i in the range this applies +// row_slack[incident_row[i]] -= reverse_coefficients[i] * delta +// then writes to out_incidence, in increasing order, the subset of i whose row is not deeply +// satisfied on both sides of the flip and returns how many. +template +int32_t fj_bin_walk_rows(int32_t* row_slack, + const int32_t* incident_row, + const coef_t* reverse_coefficients, + const coef_t* incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* out_incidence); + +// Argmax over var_score, scanning all n variables. Valid while the objective weight is zero, where +// the full score is exactly var_score. Yields best_var of -1 only if n is 0. +// Tabu is handled by "blocking" the scores corresponding to the tabu vars, and restoring them after the argmax +// affordable since max_tenure is small +void fj_bin_argmax(const int64_t* var_score, + int32_t n, + int32_t tile, + int32_t& best_var, + int64_t& best_score); + +// combined[v] = var_score[v] + obj_score[v] over n variables, which is the full score once the +// objective weight is nonzero. The three arrays must not overlap. +void fj_bin_add_scores(const int64_t* var_score, + const int64_t* obj_score, + int32_t n, + int64_t* combined); + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp new file mode 100644 index 0000000000..fd8a93ad73 --- /dev/null +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp @@ -0,0 +1,635 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +// Hot kernels of the binary CPU FJ fast path, vectorized with Google Highway. foreach_target.h +// re-includes this file once per SIMD target; HWY_EXPORT builds the dispatch table and +// HWY_DYNAMIC_DISPATCH picks at runtime. Host-compiled rather than nvcc-compiled: nvcc's frontend +// rejects Highway's x86 headers, which reinterpret-cast intrinsic vectors to compiler-specific +// vector types. + +#include + +#include +#include + +#undef HWY_TARGET_INCLUDE +#define HWY_TARGET_INCLUDE "mip_heuristics/feasibility_jump/fj_cpu_binary_kernels.cpp" +#include "hwy/foreach_target.h" // must precede highway.h +#include "hwy/highway.h" + +HWY_BEFORE_NAMESPACE(); +namespace cuopt::mathematical_optimization::mip { +namespace HWY_NAMESPACE { + +namespace hn = hwy::HWY_NAMESPACE; + +// Whether the row remainder is masked into the vector body or peeled into a scalar tail. AVX-512 +// k-registers, SVE predicates and RVV masks make every operation maskable at no cost, so a row of +// three nonzeros is one masked iteration; peeling it would send most of the work to the tail, since +// row lengths are short and have nothing to do with the lane count. AVX2 and NEON have no mask +// registers: the mask becomes a vector, gather and scatter are emulated, and the tail is cheaper. +// Measured on AVX2, masking the remainder cost 6.8% on supportcase22 and 12.9% on bnatt400. +constexpr bool k_mask_remainder = + (HWY_TARGET <= HWY_AVX3) || HWY_TARGET_IS_SVE || (HWY_TARGET == HWY_RVV); + +// Whether the row walk below is worth vectorizing on this target. It needs a real gather to read the +// slacks and a real compress to emit the tail list; where either is emulated the emulation costs +// more than the scalar loop it replaces, since 85% of visits do nothing but subtract and compare. +// The scalar arm still returns the same list, so the caller needs no second code path -- it pays +// only one store per reported visit. +constexpr bool k_vector_walk = + (HWY_TARGET <= HWY_AVX3) || HWY_TARGET_IS_SVE || (HWY_TARGET == HWY_RVV); + +// One tile of a flipped variable's incidence range, vectorized. The caller tiles the range and runs +// each tile's tail before asking for the next; see fj_bin_walk_tile. +// +// Measured on supportcase22: 84.87% of row visits leave the row deeply satisfied on both sides of +// the flip, and those visits do nothing but update the slack. The remaining 15.13% need the row's +// weight, the flipped variable's own score delta, the violated-set transitions and usually a +// patch -- all indirect, all awkward in a vector. So this kernel does only the uniform part and +// hands back the indices of the visits that are not deep_sat, in increasing order, for the caller +// to finish scalar. +// +// The layout this assumes is what makes it worth doing. Storing the row's signed slack rather than +// its lhs collapses the update to +// +// new_slack = old_slack - coef * delta +// +// so bound and lhs never appear, and the coefficient is the only per-incidence constant. It and +// cmax are replicated per incidence, which makes them unit-stride loads. What remains irregular is +// the slack itself: one gather and one scatter per vector, against four gathers and a scatter for a +// literal SoA split of the row record. +// +// Trajectory is preserved exactly. The slack update is per row and order-independent; the caller's +// tail visits its indices in the same order the scalar loop did; and a deep_sat row is never read by +// the tail, so updating it early is not observable. +template +int32_t WalkRowsImpl(int32_t* HWY_RESTRICT row_slack, + const int32_t* HWY_RESTRICT incident_row, + const coef_t* HWY_RESTRICT reverse_coefficients, + const coef_t* HWY_RESTRICT incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* HWY_RESTRICT out_incidence) +{ + int32_t n_out = 0; + int32_t ii = incidence_begin; + + if constexpr (k_vector_walk) { + const hn::ScalableTag d; + const hn::Rebind dc; // same lane count, narrower lanes + using V = hn::Vec; + const size_t N = hn::Lanes(d); + + const V vdelta = hn::Set(d, delta); + + // The unit-stride loads always run whole and read into the per-incidence padding; FirstN keeps + // the overhang out of the gather, the scatter and the compress. + for (; ii < incidence_end; ii += (int32_t)N) { + const auto active = hn::FirstN(d, (size_t)(incidence_end - ii)); + + const V rows = hn::LoadU(d, incident_row + ii); + const V skv = hn::PromoteTo(d, hn::LoadU(dc, reverse_coefficients + ii)); + const V cmax = hn::PromoteTo(d, hn::LoadU(dc, incident_row_cmax + ii)); + + const V os = hn::MaskedGatherIndex(active, d, row_slack, rows); + // os - skv * vdelta + const V ns = hn::NegMulAdd(skv, vdelta, os); + + // Only the satisfied side. deep_viol is the caller's business: it fires on 0.02% of visits but + // guards the widest rows in the matrix, so it belongs where the row length is already known. + const auto deep_sat = hn::And(hn::Gt(os, cmax), hn::Gt(ns, cmax)); + const auto to_tail = hn::AndNot(deep_sat, active); + +#if HWY_TARGET == HWY_AVX3_ZEN4 + // Same Zen 4 microcode argument as the score scatter in PatchRowBody: VPSCATTERDD is 89 uops + // at ~24 CPI, against two vector stores and N scalar stores here. Unlike that one this is a + // pure store with no read-modify-write, so it needs its own A/B before the arm is settled. + HWY_ALIGN int32_t row_lane[hn::MaxLanes(d)], slack_lane[hn::MaxLanes(d)]; + hn::Store(rows, d, row_lane); + hn::Store(ns, d, slack_lane); + const size_t lanes = HWY_MIN(N, (size_t)(incidence_end - ii)); + for (size_t i = 0; i < lanes; ++i) row_slack[row_lane[i]] = slack_lane[i]; +#else + hn::MaskedScatterIndex(ns, active, d, row_slack, rows); +#endif + + // A variable meets each row at most once, so no two lanes carry the same row and neither the + // scatter above nor the store loop needs conflict detection. + n_out += (int32_t)hn::CompressStore(hn::Iota(d, ii), to_tail, d, out_incidence + n_out); + } + return n_out; + } + + // Targets without a native gather or compress. Also the remainder is not reached here: the loop + // above runs to oe under FirstN, and this arm replaces it wholesale rather than tailing it. + for (; ii < incidence_end; ++ii) { + const int32_t row = incident_row[ii]; + const int32_t os = row_slack[row]; + const int32_t ns = os - (int32_t)reverse_coefficients[ii] * delta; + row_slack[row] = ns; + const int32_t cmax = (int32_t)incident_row_cmax[ii]; + if (!(os > cmax && ns > cmax)) out_incidence[n_out++] = ii; + } + return n_out; +} + +// Row remainder when it is peeled rather than masked, and the whole row on scalar targets. +template +void PatchRowScalar(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + for (int32_t k = kb; k < ke; ++k) { + const int32_t v = variables[k]; + if (v == skip_var) continue; + const int32_t flip = 1 - 2 * assign_i32[v]; + const int32_t ns = os_new - (int32_t)coefficients[k] * flip; + const int64_t nc = fj_bin_packed_score_delta(os_new, ns, weight); + var_score[v] += nc - nnz_score_delta[k]; + nnz_score_delta[k] = nc; + } +} + +// Templated on the vector tag so one body serves both the native-width kernel and the narrow one. +// Rows here average well under a native 512-bit vector, and a gather costs the same whether its +// lanes are used or discarded, so short rows are cheaper through a narrower vector. +template +static HWY_INLINE void PatchRowBody(D d, + const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + const hn::Rebind dc; // same lane count, narrower lanes + const hn::Repartition dw; // half the lanes, twice as wide: the packed score + using V = hn::Vec; + using VW = hn::Vec; + const size_t N = hn::Lanes(d); + const size_t NW = hn::Lanes(dw); + + // When the remainder is peeled, a row below one vector never reaches the body, so it skips the + // ten broadcasts below as well. + if constexpr (!k_mask_remainder) { + if ((size_t)(ke - kb) < N) { + PatchRowScalar(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, weight, os_new, skip_var); + return; + } + } + + const V vone = hn::Set(d, 1), vzero = hn::Zero(d); + const V vskip = hn::Set(d, skip_var); + const V vos = hn::Set(d, os_new); + const V vw = hn::Set(d, weight), vw2 = hn::Set(d, weight / 2); + + // The row's own slack is uniform across lanes, so its flags are scalars. Broadcast negated to + // match the new-state flags below, which come from VecFromMask and are 0 or -1. + const int32_t osat = os_new >= 0, ost = os_new > 0; + const V vneg_osat = hn::Set(d, -osat), vneg_ost = hn::Set(d, -ost); + const V v_not_osat = hn::Set(d, 1 - osat); + + // The loads always run unmasked and read into the per-nnz padding; when the remainder is masked, + // FirstN keeps the overhang out of the gather, the scatter and the store. + const int32_t vec_end = k_mask_remainder ? ke : ke - (int32_t)N + 1; + int32_t k = kb; + for (; k < vec_end; k += (int32_t)N) { + const V v = hn::LoadU(d, variables + k); + auto active = hn::Ne(v, vskip); + if constexpr (k_mask_remainder) { + active = hn::And(active, hn::FirstN(d, (size_t)(ke - k))); + } + + // Gathered in hardware even on Zen 4, unlike the score update below. Doing this one by lane + // instead measured 8.2% slower: it must spill the index vector and reload it 4 bytes at a time, + // which cannot store-to-load forward, and that cost 959 interlocks per iteration against 72. + // The score update escapes this because it already needs the spill for its read-modify-write. + const V a01 = hn::MaskedGatherIndex(active, d, assign_i32, v); + const V flip = hn::Sub(vone, hn::ShiftLeft<1>(a01)); + const V coef = hn::PromoteTo(d, hn::LoadU(dc, coefficients + k)); + + // vos - coef * flip + const V ns = hn::NegMulAdd(coef, flip, vos); + + // -(ns >= 0) + const V nsat_neg = hn::VecFromMask(d, hn::Ge(ns, vzero)); + // -(ns > 0) + const V nst_neg = hn::VecFromMask(d, hn::Gt(ns, vzero)); + // (ns > vos) - (ns < vos) + const V improving = + hn::Sub(hn::VecFromMask(d, hn::Lt(ns, vos)), hn::VecFromMask(d, hn::Gt(ns, vos))); + + // (1 - osat) * (1 - nsat) + const V both_violated = hn::Mul(v_not_osat, hn::Add(vone, nsat_neg)); + // vw * (nsat - osat) + both_violated * improving * vw2 + const V base = + hn::MulAdd(vw, hn::Sub(vneg_osat, nsat_neg), hn::Mul(hn::Mul(both_violated, improving), vw2)); + // vw * (nst - ost) + const V bonus = hn::Mul(vw, hn::Sub(vneg_ost, nst_neg)); + + // The score is int64, so packing it costs two vectors where the fields took one. Both fields + // are per-row here and fit int32, so they are computed at full lane count above and widened + // only for the pack. Everything below stays in the vector: the pack, the old value, the + // difference and the store back. What reaches the scalar loop is one add per nonzero, which is + // what it was before the score widened -- that loop is 38% of all cycles, so work belongs + // anywhere but there. + const VW base_lo = hn::PromoteLowerTo(dw, base); + const VW base_hi = hn::PromoteUpperTo(dw, base); + const VW bonus_lo = hn::PromoteLowerTo(dw, bonus); + const VW bonus_hi = hn::PromoteUpperTo(dw, bonus); + + const VW packed_lo = hn::Add(hn::ShiftLeft(base_lo), bonus_lo); + const VW packed_hi = hn::Add(hn::ShiftLeft(base_hi), bonus_hi); + + const VW delta_lo = hn::Sub(packed_lo, hn::LoadU(dw, nnz_score_delta + k)); + const VW delta_hi = hn::Sub(packed_hi, hn::LoadU(dw, nnz_score_delta + k + NW)); + + // The store mask is rebuilt at int64 width rather than narrowed from `active`: the same two + // conditions, on the promoted indices. FirstN is applied on every target because where the + // remainder is peeled the body never runs short, so it is all-true there anyway. + const size_t rem = (size_t)(ke - k); + const VW v_lo = hn::PromoteLowerTo(dw, v); + const VW v_hi = hn::PromoteUpperTo(dw, v); + const VW vskip_w = hn::Set(dw, skip_var); + const auto act_lo = hn::And(hn::Ne(v_lo, vskip_w), hn::FirstN(dw, rem)); + const auto act_hi = hn::And(hn::Ne(v_hi, vskip_w), hn::FirstN(dw, rem > NW ? rem - NW : 0)); + hn::BlendedStore(packed_lo, act_lo, dw, nnz_score_delta + k); + hn::BlendedStore(packed_hi, act_hi, dw, nnz_score_delta + k + NW); + +#if HWY_TARGET == HWY_AVX3_ZEN4 + // zmm VSIB is microcode on Zen 4: VPGATHERDD ~76-80 uops / ~21 CPI and VPSCATTERDD 89 / 24, + // against ~5 / ~10 and ~19 / ~11 on SPR-class Intel (Agner Fog, uops.info). So read-modify-write + // by lane here; measured +5.8% over the arm below on an EPYC 9554 (supportcase22, 16 climbers). + HWY_ALIGN int32_t idx[hn::MaxLanes(d)]; + HWY_ALIGN int64_t dl[hn::MaxLanes(d)]; + hn::Store(v, d, idx); + hn::Store(delta_lo, dw, dl); + hn::Store(delta_hi, dw, dl + NW); + // Bounded by the row, not the vector: the lanes past it hold padding, whose zero index would + // otherwise be applied to variable 0. + const size_t lanes = HWY_MIN(N, (size_t)(ke - k)); + for (size_t i = 0; i < lanes; ++i) { + if (idx[i] != skip_var) var_score[idx[i]] += dl[i]; + } +#else + // The score is int64, so the gather and scatter run at the promoted width against the promoted + // indices, in the two halves the pack already produced. + const VW cur_lo = hn::MaskedGatherIndex(act_lo, dw, var_score, v_lo); + const VW cur_hi = hn::MaskedGatherIndex(act_hi, dw, var_score, v_hi); + hn::MaskedScatterIndex(hn::Add(cur_lo, delta_lo), act_lo, dw, var_score, v_lo); + hn::MaskedScatterIndex(hn::Add(cur_hi, delta_hi), act_hi, dw, var_score, v_hi); +#endif + } + + if constexpr (!k_mask_remainder) { + PatchRowScalar(variables, coefficients, k, ke, var_score, nnz_score_delta, assign_i32, + weight, os_new, skip_var); + } +} + +// Native width, and the 8-lane variant for rows that would leave most of a native vector idle. +template +void PatchRowImpl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::ScalableTag(), variables, coefficients, kb, ke, var_score, + nnz_score_delta, assign_i32, weight, os_new, skip_var); +} + +template +void PatchRowNarrow8Impl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, + var_score, nnz_score_delta, assign_i32, weight, os_new, skip_var); +} + +template +void PatchRowNarrow4Impl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + PatchRowBody(hn::CappedTagIfFixed(), variables, coefficients, kb, ke, + var_score, nnz_score_delta, assign_i32, weight, os_new, skip_var); +} + +// Longest row worth sending to each narrower kernel, or 0 where that width is not worth having. +// A gather costs the same whether its lanes carry data or are masked off, so a row that fills only +// part of a native vector is cheaper through a narrower one; past the crossover the extra vector +// and its extra full gather cost more than the wasted lanes. From the Zen 4 microcode ratio +// (VPGATHERDD ~78 uops at 512 bits, 48 at 256, 24 at 128) the crossovers land at 4 and 8. +// +// A width is offered only when it is strictly narrower than the native vector, so no target ever +// dispatches to a kernel identical to its own. Scalable targets opt out entirely: Highway notes +// that clamping Lanes() on RVV/SVE can cost more than the capping saves, which is why +// CappedTagIfFixed leaves them at native width above. +// +// These are per-target constants, so the width choice belongs here rather than at the call seam: a +// caller outside this file can only reach them through a dispatch pointer, which turns two +// immediates into two loads of runtime globals and puts an unpredictable branch directly in front +// of the indirect jump that follows it. Measured on supportcase22, that seam cost 2.4%. +constexpr size_t k_native_lanes = HWY_MAX_LANES_D(hn::ScalableTag); +constexpr int32_t k_narrow4_max = HWY_HAVE_SCALABLE ? 0 : (k_native_lanes > 4 ? 4 : 0); +constexpr int32_t k_narrow8_max = HWY_HAVE_SCALABLE ? 0 : (k_native_lanes > 8 ? 8 : 0); + +// Single entry point the seam dispatches to. On a scalable target both bounds are 0, so both +// compares fold away and the narrow arms are stripped. +template +void PatchRowDispatchImpl(const int32_t* HWY_RESTRICT variables, + const coef_t* HWY_RESTRICT coefficients, + int32_t kb, + int32_t ke, + int64_t* HWY_RESTRICT var_score, + int64_t* HWY_RESTRICT nnz_score_delta, + const int32_t* HWY_RESTRICT assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + const int32_t row_len = ke - kb; + if (row_len <= k_narrow4_max) { + PatchRowNarrow4Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, weight, os_new, skip_var); + } else if (row_len <= k_narrow8_max) { + PatchRowNarrow8Impl(variables, coefficients, kb, ke, var_score, nnz_score_delta, + assign_i32, weight, os_new, skip_var); + } else { + PatchRowImpl(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, + weight, os_new, skip_var); + } +} + +// Tiled sweep carrying a running maximum. The index re-scan fires only on a tile that raises it, +// and that tile is still cache-hot. The tabu window is uint16 against int32 scores, so the mask +// crosses a 2:1 width boundary through PromoteMaskTo. +void ArgmaxImpl(const int64_t* HWY_RESTRICT var_score, + int32_t n, + int32_t tile, + int32_t* best_var, + int64_t* best_score) +{ + const hn::ScalableTag d; + using V = hn::Vec; + + const int32_t step = (int32_t)hn::Lanes(d); + const V vmin = hn::Set(d, fj_bin_score_invalid); + + // Whole vectors only; the remainder is scanned scalar below. + const int32_t nblk = n - (n % step); + int32_t tile_step = tile - (tile % step); + if (tile_step < step) tile_step = step; + + int32_t bv = -1; + int64_t bs = fj_bin_score_invalid; + + for (int32_t t0 = 0; t0 < nblk; t0 += tile_step) { + const int32_t t1 = (t0 + tile_step < nblk) ? t0 + tile_step : nblk; + + V tile_max = vmin; + for (int32_t v = t0; v < t1; v += step) { + tile_max = hn::Max(tile_max, hn::LoadU(d, var_score + v)); + } + + const int64_t peak = hn::ReduceMax(d, tile_max); + if (peak > bs) { + const V vpeak = hn::Set(d, peak); + for (int32_t v = t0; v < t1; v += step) { + const intptr_t lane = hn::FindFirstTrue(d, hn::Eq(hn::LoadU(d, var_score + v), vpeak)); + if (lane >= 0) { + bv = v + (int32_t)lane; + break; + } + } + bs = peak; + } + } + + for (int32_t v = nblk; v < n; ++v) { + if (var_score[v] > bs) { + bs = var_score[v]; + bv = v; + } + } + + *best_var = bv; + *best_score = bs; +} + +// combined[v] = var_score[v] + obj_score[v] over all n variables. Materialized rather than fused +// into the argmax because block_tabu writes sentinels into the result and restores them afterwards, +// so the array has to outlive the scan. None of the three has SIMD padding, hence the scalar tail. +void AddScoresImpl(const int64_t* HWY_RESTRICT var_score, + const int64_t* HWY_RESTRICT obj_score, + int32_t n, + int64_t* HWY_RESTRICT combined) +{ + const hn::ScalableTag d; + const int32_t step = (int32_t)hn::Lanes(d); + const int32_t nblk = n - (n % step); + + for (int32_t v = 0; v < nblk; v += step) { + hn::StoreU(hn::Add(hn::LoadU(d, var_score + v), hn::LoadU(d, obj_score + v)), d, combined + v); + } + for (int32_t v = nblk; v < n; ++v) { + combined[v] = var_score[v] + obj_score[v]; + } +} + +} // namespace HWY_NAMESPACE +} // namespace cuopt::mathematical_optimization::mip +HWY_AFTER_NAMESPACE(); + +#if HWY_ONCE +namespace cuopt::mathematical_optimization::mip { + +// One dispatch table per (coefficient width, vector width). HWY_EXPORT_T names the table +// separately from the function, which lets the function be a template-id: only the table name goes +// through token pasting, so no hand-written non-template wrapper is needed. The template argument +// must stay comma-free, which is why the three tag-binding wrappers above take only coef_t. +HWY_EXPORT_T(PatchRowI8, PatchRowDispatchImpl); +HWY_EXPORT_T(PatchRowI16, PatchRowDispatchImpl); +HWY_EXPORT_T(WalkRowsI8, WalkRowsImpl); +HWY_EXPORT_T(WalkRowsI16, WalkRowsImpl); +HWY_EXPORT(ArgmaxImpl); +HWY_EXPORT(AddScoresImpl); + +// HWY_DYNAMIC_DISPATCH resolves the target on every call, and the hwy::GetChosenTarget() call it +// expands to is a real out-of-line call: it clobbers the argument registers, so the compiler spills +// all eleven parameters to the stack and reloads them around it. These run once per row per move, +// so the pointers are resolved once instead. +// +// Entry 0 of a dispatch table is a trampoline that chooses the target and re-dispatches, and an +// unchosen target makes GetIndex() return 0. Caching then would pin that extra indirection for the +// process lifetime, so the target is chosen first. File scope rather than function scope keeps the +// guard variable of a magic static out of the call: its cold path can call __cxa_guard_acquire, so +// the compiler must preserve the arguments across it and cannot leave a bare tail jump. Nothing in +// cuOpt reaches feasibility jump during static initialization. +static void fj_bin_choose_target() +{ + if (!hwy::GetChosenTarget().IsInitialized()) { + hwy::GetChosenTarget().Update(hwy::SupportedTargets()); + } +} + +template +using fj_bin_patch_fn_t = void (*)(const int32_t*, + const coef_t*, + int32_t, + int32_t, + int64_t*, + int64_t*, + const int32_t*, + int32_t, + int32_t, + int32_t); + +// The vector width is chosen inside the target (see PatchRowDispatchImpl), so the seam carries one +// pointer per coefficient width and nothing else. +static const auto fj_bin_patch_i8 = + (fj_bin_choose_target(), (fj_bin_patch_fn_t)HWY_DYNAMIC_POINTER_T(PatchRowI8)); +static const auto fj_bin_patch_i16 = + (fj_bin_choose_target(), (fj_bin_patch_fn_t)HWY_DYNAMIC_POINTER_T(PatchRowI16)); + +// Overloaded rather than specialized, matching fj_bin_walk_fn below. +static fj_bin_patch_fn_t fj_bin_patch_fn(int8_t) { return fj_bin_patch_i8; } +static fj_bin_patch_fn_t fj_bin_patch_fn(int16_t) { return fj_bin_patch_i16; } + +template +using fj_bin_walk_fn_t = int32_t (*)( + int32_t*, const int32_t*, const coef_t*, const coef_t*, int32_t, int32_t, int32_t, int32_t*); + +static const auto fj_bin_walk_i8 = + (fj_bin_choose_target(), (fj_bin_walk_fn_t)HWY_DYNAMIC_POINTER_T(WalkRowsI8)); +static const auto fj_bin_walk_i16 = + (fj_bin_choose_target(), (fj_bin_walk_fn_t)HWY_DYNAMIC_POINTER_T(WalkRowsI16)); + +static fj_bin_walk_fn_t fj_bin_walk_fn(int8_t) { return fj_bin_walk_i8; } +static fj_bin_walk_fn_t fj_bin_walk_fn(int16_t) { return fj_bin_walk_i16; } + +static const auto fj_bin_argmax_fn = (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(ArgmaxImpl)); +static const auto fj_bin_add_scores_fn = + (fj_bin_choose_target(), HWY_DYNAMIC_POINTER(AddScoresImpl)); + +template +int32_t fj_bin_walk_rows(int32_t* row_slack, + const int32_t* incident_row, + const coef_t* reverse_coefficients, + const coef_t* incident_row_cmax, + int32_t incidence_begin, + int32_t incidence_end, + int32_t delta, + int32_t* out_incidence) +{ + return fj_bin_walk_fn(coef_t{})(row_slack, + incident_row, + reverse_coefficients, + incident_row_cmax, + incidence_begin, + incidence_end, + delta, + out_incidence); +} + +template int32_t fj_bin_walk_rows( + int32_t*, const int32_t*, const int8_t*, const int8_t*, int32_t, int32_t, int32_t, int32_t*); +template int32_t fj_bin_walk_rows( + int32_t*, const int32_t*, const int16_t*, const int16_t*, int32_t, int32_t, int32_t, int32_t*); + +template +void fj_bin_patch_row(const int32_t* variables, + const coef_t* coefficients, + int32_t kb, + int32_t ke, + int64_t* var_score, + int64_t* nnz_score_delta, + const int32_t* assign_i32, + int32_t weight, + int32_t os_new, + int32_t skip_var) +{ + fj_bin_patch_fn(coef_t{})(variables, coefficients, kb, ke, var_score, nnz_score_delta, assign_i32, + weight, os_new, skip_var); +} + +template void fj_bin_patch_row(const int32_t*, + const int8_t*, + int32_t, + int32_t, + int64_t*, + int64_t*, + const int32_t*, + int32_t, + int32_t, + int32_t); + +template void fj_bin_patch_row(const int32_t*, + const int16_t*, + int32_t, + int32_t, + int64_t*, + int64_t*, + const int32_t*, + int32_t, + int32_t, + int32_t); + +void fj_bin_argmax(const int64_t* var_score, + int32_t n, + int32_t tile, + int32_t& best_var, + int64_t& best_score) +{ + fj_bin_argmax_fn(var_score, n, tile, &best_var, &best_score); +} + +void fj_bin_add_scores(const int64_t* var_score, + const int64_t* obj_score, + int32_t n, + int64_t* combined) +{ + fj_bin_add_scores_fn(var_score, obj_score, n, combined); +} + +} // namespace cuopt::mathematical_optimization::mip +#endif // HWY_ONCE diff --git a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh index ff6022c4c2..b30081059b 100644 --- a/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh +++ b/cpp/src/mip_heuristics/feasibility_jump/fj_cpu_worker.cuh @@ -10,6 +10,8 @@ #include #include +#include + #include #include #include @@ -22,28 +24,44 @@ namespace cuopt::mathematical_optimization::mip { template struct fj_cpu_climber_t; +template +struct fj_cpu_shared_incumbent_t; + +// Defined in fj_cpu.cu, where the type is complete. +template +std::shared_ptr> make_fj_cpu_shared_incumbent(); + template struct fj_cpu_worker_t { // Custom deleter to avoid pulling the entire fj_cpu_climber_t class here. struct fj_cpu_deleter_t { void operator()(fj_cpu_climber_t* ptr) const; }; + + std::atomic is_initialized{false}; std::atomic preemption_flag{false}; std::unique_ptr, fj_cpu_deleter_t> fj_cpu; std::function&, double)> improvement_callback; + // Set before create_worker to join a portfolio; left null when the climber runs alone. + std::shared_ptr> shared_incumbent; ~fj_cpu_worker_t() { stop(); } + // `n_structural` is where `problem`'s slack block starts; those columns fold into two-sided row + // bounds, so the climber and the assignment it reports span only the ones below. -1 keeps them. // `seed` selects the FJ RNG seed: pass a non-negative value for a deterministic seed, // or -1 to draw from the global cuopt::seed_generator (the historical behavior). // In deterministic mode the caller MUST pass an explicit seed, otherwise the underlying // seed_generator::get_seed() racing with concurrent callers breaks reproducibility. + // `lane` >= 0 applies that lane's persona from the portfolio diversification ladder. void create_worker(const simplex::lp_problem_t& problem, const std::vector& variable_types, + i_t n_structural, const std::vector& seed_assignment, const simplex::simplex_solver_settings_t& settings, std::string log_prefix, - int64_t seed = -1); + int64_t seed = -1, + int lane = -1); // Run the worker asynchronously (i.e., launch an openmp task and then continue the // execution). Call `stop()` for stopping the worker @@ -55,6 +73,8 @@ struct fj_cpu_worker_t { double work_unit_limit = std::numeric_limits::infinity()); void stop(); + + void send_stop_signal(); }; } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/local_search/local_search.cu b/cpp/src/mip_heuristics/local_search/local_search.cu index 75c4185949..c1d80fcda7 100644 --- a/cpp/src/mip_heuristics/local_search/local_search.cu +++ b/cpp/src/mip_heuristics/local_search/local_search.cu @@ -71,6 +71,7 @@ void local_search_t::start_cpufj_scratch_threads(population_t 0); @@ -78,6 +79,7 @@ void local_search_t::start_cpufj_scratch_threads(population_timprovement_callback = [this, &population, problem_ptr = context.problem_ptr]( f_t obj, const std::vector& h_vec, double /*work_units*/) { + context.solution_publication.publish_if_better(problem_ptr, h_vec, obj); population.add_external_solution(h_vec, obj, solution_origin_t::CPUFJ); (void)problem_ptr; if (obj < this->local_search_best_obj) { @@ -117,11 +119,16 @@ void local_search_t::start_cpufj_lptopt_scratch_threads( solution_lp.copy_new_assignment( host_copy(lp_optimal_solution, context.problem_ptr->handle_ptr->get_stream())); solution_lp.round_random_nearest(500); - scratch_cpu_fj_on_lp_opt = fj.create_cpu_climber( - solution_lp, default_weights, default_weights, 0., context.preempt_heuristic_solver_); + scratch_cpu_fj_on_lp_opt = fj.create_cpu_climber(solution_lp, + default_weights, + default_weights, + 0., + context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache); scratch_cpu_fj_on_lp_opt->log_prefix = "******* scratch on LP optimal: "; scratch_cpu_fj_on_lp_opt->improvement_callback = [this, &population](f_t obj, const std::vector& h_vec, double /*work_units*/) { + context.solution_publication.publish_if_better(context.problem_ptr, h_vec, obj); population.add_external_solution(h_vec, obj, solution_origin_t::CPUFJ); if (obj < this->local_search_best_obj) { CUOPT_LOG_DEBUG("******* New local search best obj %g, best overall %g", @@ -145,8 +152,11 @@ void local_search_t::stop_cpufj_scratch_threads() { if (omp_get_num_threads() < CUOPT_MIP_FJ_REQUIRED_THREAD_COUNT) return; + for (auto& cpu_fj : scratch_cpu_fj) { + cuopt_assert(cpu_fj != nullptr, "scratch climbers must have been created"); + cpu_fj->halted = true; + } for (size_t i = 0; i < scratch_cpu_fj.size(); ++i) { - scratch_cpu_fj[i]->halted = true; #pragma omp taskwait depend(in : *scratch_cpu_fj[i]) // Wait for each scratch CPU FJ task to finish } @@ -183,6 +193,7 @@ void local_search_t::start_cpufj_deterministic(mip::branch_and_bound_t default_weights, 0., context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache, fj_settings_t{}, /*randomize=*/true); @@ -258,6 +269,7 @@ bool local_search_t::do_fj_solve(solution_t& solution, h_weights, h_objective_weight, context.preempt_heuristic_solver_, + &constraint_prop.bounds_update.probing_cache, fj_settings_t{}, true); } diff --git a/cpp/src/mip_heuristics/mip_constants.hpp b/cpp/src/mip_heuristics/mip_constants.hpp index f3fb68343a..e5c85a65fa 100644 --- a/cpp/src/mip_heuristics/mip_constants.hpp +++ b/cpp/src/mip_heuristics/mip_constants.hpp @@ -21,6 +21,14 @@ #define CUOPT_MIP_BATCH_PDLP_REQUIRED_THREAD_COUNT 3 #define CUOPT_MIP_CLIQUE_CUTS_REQUIRED_THREAD_COUNT 3 +/* @brief Threads the early CPUFJ portfolio leaves to the rest of the team. Every lane holds its + * own host copy of the problem and occupies an OMP task for the whole of presolve. */ +#define CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS 4 + +/* @brief Upper bound on the persistent root CPUFJ lane set. Every lane holds its own host copy of + * the root LP and occupies an OMP task for the whole of the cut loop. */ +#define CUOPT_MIP_ROOT_CPUFJ_MAX_LANES 4 + // 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. #define CUOPT_CONCURRENT_LP_BARRIER_REQUIRED_THREAD_COUNT 3 diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp new file mode 100644 index 0000000000..4d6de0c8d4 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.cpp @@ -0,0 +1,620 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "bhw_coeff_reduce.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Gordon H. Bradley, Peter L. Hammer, Laurence Wolsey (1974) Coefficient reduction for inequalities +// in 0-1 variables. Mathematical Programming 7:263-282. +// +// Over binaries many inequalities share the same 0/1 feasible set. Theorem 2.5 characterizes that +// set of "equivalent" inequalities by a system over the ceilings and roofs of the row. The test +// here is the same characterization taken over the plain maximal feasible and minimal infeasible +// points, which are supersets of BHW's ceilings and roofs because we skip their ordering condition. +// That costs nothing: with all coefficients positive the activity is monotone, so the maximum of +// w.x over the feasible points is attained at a maximal one and the minimum over the infeasible +// points at a minimal one. +// +// BHW's minimization is not implemented. Section 5 obtains the minimum equivalent inequality by LP +// over the polytope. We instead search weight vectors by increasing max|w| up to a cap, which is +// minimal by construction below the cap, and fall back to two heuristic candidates above it. That +// search is affordable at every width we enumerate only because conditions N1 and N3 restrict +// candidates to non-negative non-increasing sequences: at most sum_{m=1..6} C(11+m, m) = 18563 +// vectors for a 12-entry row. Lemma 3.6 supplies the lower bound that seeds and prunes the search. +// +// TODO: extend the fallback path to the row generation of Section 6, which reaches rows the two +// heuristics below leave at a larger magnitude than necessary. The separation oracle in step 3 is a +// 0-1 knapsack, max w.x subject to a.x <= b, which solve_knapsack_problem in cuts/cuts.hpp already +// solves by DP. Boyd (1993) Generating Fenchel Cutting Planes for Knapsack Polyhedra, SIAM J. +// Optim. 3(4):734-750, treats the same separation problem as cut generation. + +namespace cuopt::mathematical_optimization::mip { + +namespace { + +// Rows between two interrupt checks in execute; screening one row is cheap next to the check. +constexpr int BHW_INTERRUPT_CHECK_STRIDE = 256; + +// One row in BHW's normalized frame: condition N1 (every coefficient positive, negatives +// complemented by x_i = 1 - y_i) and condition N3 (coefficients sorted descending). N1 is what +// makes the row activity monotone in x, and it also makes the weights of any equivalent inequality +// non-negative, since a variable that tightens the row must tighten every inequality with the same +// feasible set. Both normalizations are undone before any reduction is returned. +struct norm_row_t { + int k = 0; + std::array coef{}; // descending, all > 0 + std::array slot{}; // position of this entry in the caller's row arrays + std::array flipped{}; // whether this entry was complemented by N1 + int64_t rhs = 0; +}; + +struct partition_t { + std::vector maximal_feasible; + std::vector minimal_infeasible; + // Lemma 3.6: where coef[i] > coef[i+1] and the two variables are not symmetric, every equivalent + // inequality has w_i >= w_{i+1} + 1. Chaining those steps down to w_{k-1} >= 0 bounds max|w|. + std::array strict{}; + std::array suffix_strict{}; + int lemma36_bound = 0; +}; + +int64_t weight_activity(const int64_t* w, uint32_t mask) +{ + int64_t sum = 0; + while (mask != 0u) { + sum += w[std::countr_zero(mask)]; + mask &= mask - 1u; + } + return sum; +} + +// Splits {0,1}^k and collects the maximal feasible and minimal infeasible points. Returns false for +// a degenerate row (all points feasible or all infeasible), which is left to other presolvers. +bool build_partition(const norm_row_t& row, partition_t& out) +{ + const int k = row.k; + const uint32_t n_pat = 1u << k; + cuopt_assert(k >= 2 && k <= BHW_MAX_LEN, "row length outside the enumerable range"); + + std::vector activity(n_pat, 0); + std::vector feasible(n_pat, 0); + uint32_t n_feasible = 0; + for (uint32_t m = 1; m < n_pat; ++m) + activity[m] = activity[m & (m - 1u)] + row.coef[std::countr_zero(m)]; + for (uint32_t m = 0; m < n_pat; ++m) { + feasible[m] = activity[m] <= row.rhs ? 1 : 0; + n_feasible += feasible[m]; + } + if (n_feasible == 0 || n_feasible == n_pat) return false; + + for (uint32_t m = 0; m < n_pat; ++m) { + bool extremal = true; + if (feasible[m] != 0) { + for (int i = 0; i < k && extremal; ++i) + if ((m >> i & 1u) == 0u && feasible[m | (1u << i)] != 0) extremal = false; + if (extremal) out.maximal_feasible.push_back(m); + } else { + for (int i = 0; i < k && extremal; ++i) + if ((m >> i & 1u) != 0u && feasible[m ^ (1u << i)] == 0) extremal = false; + if (extremal) out.minimal_infeasible.push_back(m); + } + } + cuopt_assert(!out.maximal_feasible.empty() && !out.minimal_infeasible.empty(), + "a non-degenerate partition has at least one extremal point on each side"); + + for (int i = 0; i + 1 < k; ++i) { + if (row.coef[i] <= row.coef[i + 1]) continue; + const uint32_t lo_bit = 1u << i; + const uint32_t hi_bit = 1u << (i + 1); + for (uint32_t m = 0; m < n_pat; ++m) { + if ((m & lo_bit) != 0u || (m & hi_bit) == 0u) continue; + // coef[i] > coef[i+1], so moving the set bit down raises the activity: a feasible point whose + // swap is infeasible witnesses that the two variables are not interchangeable. + if (feasible[m] != 0 && feasible[(m ^ hi_bit) | lo_bit] == 0) { + out.strict[i] = true; + break; + } + } + } + for (int i = k - 1; i >= 0; --i) + out.suffix_strict[i] = out.suffix_strict[i + 1] + (out.strict[i] ? 1 : 0); + out.lemma36_bound = out.suffix_strict[0]; + cuopt_assert(out.lemma36_bound < k, "at most k-1 strict steps exist in a row of length k"); + return true; +} + +// BHW Theorem 2.5: (w, t) is equivalent to the row iff every maximal feasible point M satisfies +// sum_{i in M} w_i <= t and every minimal infeasible point satisfies sum_{i in .} w_i >= t + 1. +// Taking t as the feasible-side maximum makes the first block hold by construction, leaving hi < +// lo; integer weights make "> t" and ">= t+1" the same statement. +bool accepts(const partition_t& part, const int64_t* w, int64_t& bound) +{ + int64_t hi = std::numeric_limits::min(); + for (uint32_t m : part.maximal_feasible) + hi = std::max(hi, weight_activity(w, m)); + int64_t lo = std::numeric_limits::max(); + for (uint32_t m : part.minimal_infeasible) + lo = std::min(lo, weight_activity(w, m)); + bound = hi; + return hi < lo; +} + +// The rewritten row must not enlarge this row's LP relaxation: every x in [0,1]^k with w.x <= t has +// to satisfy a.x <= rhs. With a > 0 and w >= 0 after N1 that is a fractional knapsack, max a.x +// subject to w.x <= t, solved by taking the zero-weight entries for free and then filling capacity +// in decreasing a_i/w_i order. Greedy leaves at most one fractional entry, so the comparison closes +// exactly in rationals and no tolerance is needed. The 0-1 knapsack DP in cuts/cuts.hpp cannot be +// substituted here: its optimum is a lower bound on the continuous one, which would let a weakening +// row through. +bool lp_no_weakening(const norm_row_t& row, const int64_t* w, int64_t t) +{ + cuopt_assert(t >= 0, "acceptance implies the origin is feasible, so the bound is non-negative"); + + std::array order{}; + int n_items = 0; + __int128 value = 0; + for (int i = 0; i < row.k; ++i) { + if (w[i] == 0) + value += row.coef[i]; + else + order[n_items++] = i; + } + std::sort(order.begin(), order.begin() + n_items, [&](int x, int y) { + const __int128 dx = (__int128)row.coef[x] * w[y]; + const __int128 dy = (__int128)row.coef[y] * w[x]; + return dx != dy ? dx > dy : x < y; + }); + + int64_t capacity = t; + int fractional = -1; + for (int p = 0; p < n_items; ++p) { + const int i = order[p]; + if (w[i] <= capacity) { + value += row.coef[i]; + capacity -= w[i]; + } else { + if (capacity > 0) fractional = i; + break; + } + } + + if (fractional < 0) return value <= (__int128)row.rhs; + return value * w[fractional] + (__int128)capacity * row.coef[fractional] <= + (__int128)row.rhs * w[fractional]; +} + +// Debug companion to accepts(): checks the 0/1 partition over every point rather than the extremal +// ones the search relies on. +[[maybe_unused]] bool verify_equivalent(const norm_row_t& row, const int64_t* w, int64_t t) +{ + const uint32_t n_pat = 1u << row.k; + for (uint32_t m = 0; m < n_pat; ++m) { + int64_t a_activity = 0; + int64_t w_activity = 0; + for (int i = 0; i < row.k; ++i) { + if ((m >> i & 1u) == 0u) continue; + a_activity += row.coef[i]; + w_activity += w[i]; + } + if ((a_activity <= row.rhs) != (w_activity <= t)) return false; + } + return true; +} + +struct search_state_t { + const norm_row_t* row = nullptr; + const partition_t* part = nullptr; + std::array w{}; + std::array best_w{}; + int64_t best_bound = 0; + int best_nonzeros = 0; + int64_t best_sum = 0; + bool found = false; +}; + +// Enumerates the non-increasing weight vectors reachable from the prefix fixed so far. N1 makes the +// weights non-negative and N3 makes them non-increasing, so a candidate is just a non-increasing +// sequence bounded by w[0]; Lemma 3.6's strict steps both shrink the branching factor and bound how +// low a position may go while still leaving room for the steps beneath it. +void search_positions(search_state_t& st, int pos) +{ + const int k = st.row->k; + if (pos == k) { + int64_t bound = 0; + if (!accepts(*st.part, st.w.data(), bound)) return; + if (!lp_no_weakening(*st.row, st.w.data(), bound)) return; + + int nonzeros = 0; + int64_t sum = 0; + for (int i = 0; i < k; ++i) { + nonzeros += st.w[i] != 0 ? 1 : 0; + sum += st.w[i]; + } + // Same max|w| by construction at this depth, so prefer dropping variables, then smaller + // weights. + if (st.found && + (nonzeros > st.best_nonzeros || (nonzeros == st.best_nonzeros && sum >= st.best_sum))) + return; + st.found = true; + st.best_nonzeros = nonzeros; + st.best_sum = sum; + st.best_bound = bound; + st.best_w = st.w; + return; + } + + const int64_t upper = st.w[pos - 1] - (st.part->strict[pos - 1] ? 1 : 0); + const int64_t lower = st.part->suffix_strict[pos]; + for (int64_t v = upper; v >= lower; --v) { + st.w[pos] = v; + search_positions(st, pos + 1); + } +} + +// Fallback for the rows whose smallest equivalent magnitude exceeds BHW_EXACT_MAX_WEIGHT, where the +// exhaustive search gives up: w = round(a / min a) and the all-ones clause form, both put through +// the same acceptance and LP-strength gates as a searched vector. N3 already sorted a, so both +// candidates are non-increasing. +bool heuristic_reduce(const norm_row_t& row, + const partition_t& part, + std::vector& weights, + int64_t& bound) +{ + const int k = row.k; + const int64_t a_min = row.coef[k - 1]; + cuopt_assert(a_min > 0, "N1 leaves every coefficient positive"); + + // Only a strict gain is worth installing: smaller magnitude, or the same magnitude with a + // variable dropped. + int64_t best_max = row.coef[0]; + int best_nonzeros = k; + bool found = false; + + std::array candidate{}; + for (int variant = 0; variant < 2; ++variant) { + for (int i = 0; i < k; ++i) + candidate[i] = variant == 0 ? (row.coef[i] + a_min / 2) / a_min : 1; + + int64_t candidate_bound = 0; + if (!accepts(part, candidate.data(), candidate_bound)) continue; + if (!lp_no_weakening(row, candidate.data(), candidate_bound)) continue; + + int64_t candidate_max = 0; + int nonzeros = 0; + for (int i = 0; i < k; ++i) { + candidate_max = std::max(candidate_max, candidate[i]); + nonzeros += candidate[i] != 0 ? 1 : 0; + } + if (candidate_max > best_max || (candidate_max == best_max && nonzeros >= best_nonzeros)) + continue; + + found = true; + best_max = candidate_max; + best_nonzeros = nonzeros; + weights.assign(candidate.begin(), candidate.begin() + k); + bound = candidate_bound; + } + return found; +} + +// Reduce one normalized shape. The caller undoes N1/N3 on the result. +bool reduce_shape(const norm_row_t& row, std::vector& weights, int64_t& bound) +{ + partition_t part; + if (!build_partition(row, part)) return false; + + const int64_t current = row.coef[0]; // N3 puts the largest coefficient first + cuopt_assert(current >= 2, "rows already at magnitude one are rejected before normalization"); + // Lemma 3.6 bounds max|w| from below over every equivalent inequality, so this row is provably + // irreducible in magnitude and not worth searching. + if (part.lemma36_bound >= current) return false; + + search_state_t st; + st.row = &row; + st.part = ∂ + const int64_t m_high = std::min(BHW_EXACT_MAX_WEIGHT, current - 1); + for (int64_t m = std::max(part.lemma36_bound, 1); m <= m_high; ++m) { + st.found = false; + st.w[0] = m; + search_positions(st, 1); + if (!st.found) continue; + // First m with any acceptance, so this is the minimum achievable max|w|. + weights.assign(st.best_w.begin(), st.best_w.begin() + row.k); + bound = st.best_bound; + return true; + } + return heuristic_reduce(row, part, weights, bound); +} + +} // namespace + +template +bhw_row_rewrite_t bhw_reduce_row( + const f_t* coefficients, int len, f_t side, int direction, bhw_shape_cache_t* cache) +{ + cuopt_assert(direction == 1 || direction == -1, + "direction is the sign that orients the row to <="); + bhw_row_rewrite_t rewrite; + if (len < 2 || len > BHW_MAX_LEN) return rewrite; + if (!scaling_bound_finite(side)) return rewrite; + + // Integerize so the point partition is exact, then orient the row to a.x <= b. + const double scale = row_int_scale( + coefficients, len, side, std::numeric_limits::infinity(), BHW_MAX_LEN, BHW_INT_SCALE_MAX); + if (scale == 0.0) return rewrite; + + std::array integral{}; + int64_t largest = 0; + for (int j = 0; j < len; ++j) { + integral[j] = std::llround((double)coefficients[j] * scale) * direction; + if (integral[j] == 0) return rewrite; + largest = std::max(largest, std::abs(integral[j])); + } + // A row already at +/-1 has no magnitude to give back; rejecting it here is what keeps screening + // cheap on the rows that dominate a model. + if (largest <= 1) return rewrite; + + norm_row_t norm_row; + norm_row.k = len; + norm_row.rhs = std::llround((double)side * scale) * direction; + std::array order{}; + for (int j = 0; j < len; ++j) { + order[j] = j; + // N1: complementing x_j = 1 - y_j moves the negative coefficient onto the right-hand side. + if (integral[j] < 0) norm_row.rhs -= integral[j]; + } + // N3: descending by magnitude, ties broken by position so the shape key is deterministic. + std::sort(order.begin(), order.begin() + len, [&](int x, int y) { + const int64_t ax = std::abs(integral[x]); + const int64_t ay = std::abs(integral[y]); + return ax != ay ? ax > ay : x < y; + }); + for (int p = 0; p < len; ++p) { + const int j = order[p]; + norm_row.coef[p] = std::abs(integral[j]); + norm_row.slot[p] = j; + norm_row.flipped[p] = integral[j] < 0; + } + + bhw_shape_result_t computed; + const bhw_shape_result_t* result = nullptr; + if (cache != nullptr) { + std::vector key(norm_row.coef.begin(), norm_row.coef.begin() + len); + key.push_back(norm_row.rhs); + auto cached = cache->find(key); + if (cached == cache->end()) { + bhw_shape_result_t fresh; + fresh.accepted = reduce_shape(norm_row, fresh.weights, fresh.bound); + cached = cache->emplace(std::move(key), std::move(fresh)).first; + } + result = &cached->second; + } else { + computed.accepted = reduce_shape(norm_row, computed.weights, computed.bound); + result = &computed; + } + if (!result->accepted) return rewrite; + + cuopt_assert((int)result->weights.size() == len, "cached shape has the wrong length"); + cuopt_assert(*std::min_element(result->weights.begin(), result->weights.end()) >= 0, + "N1 leaves the reduced weights non-negative"); + cuopt_assert( + *std::max_element(result->weights.begin(), result->weights.end()) == result->weights[0], + "N3 leaves the reduced weights non-increasing"); + cuopt_assert(result->weights[0] < norm_row.coef[0] || + std::count(result->weights.begin(), result->weights.end(), 0) > 0, + "an accepted rewrite must shrink the magnitude or drop a variable"); + cuopt_assert(verify_equivalent(norm_row, result->weights.data(), result->bound), + "BHW rewrite changed the 0/1 feasible set"); + + // Undo N3 and N1, then undo the orientation. Complementing back turns w_i y_i into w_i - w_i x_i, + // which flips the coefficient and moves w_i onto the bound. + rewrite.coefficients.assign(len, 0); + int64_t new_side = result->bound; + for (int p = 0; p < len; ++p) { + const int j = norm_row.slot[p]; + if (norm_row.flipped[p]) { + rewrite.coefficients[j] = -result->weights[p]; + new_side -= result->weights[p]; + } else { + rewrite.coefficients[j] = result->weights[p]; + } + } + for (int j = 0; j < len; ++j) + rewrite.coefficients[j] *= direction; + rewrite.side = new_side * direction; + rewrite.max_coef_before = norm_row.coef[0]; + rewrite.max_coef_after = result->weights[0]; + rewrite.accepted = true; + return rewrite; +} + +namespace { + +// Coefficient-shrink figures behind the DEBUG line. Both the accumulation and the summary compile +// away below DEBUG, so a release build carries neither the per-row vector nor the selection. +struct bhw_stats_t { +#if (CUOPT_LOG_ACTIVE_LEVEL <= RAPIDS_LOGGER_LOG_LEVEL_DEBUG) + int64_t coefficients_reduced = 0; + int64_t coefficients_dropped = 0; + std::vector row_shrinks; + + void changed_coefficient(int64_t new_coefficient) + { + ++coefficients_reduced; + coefficients_dropped += new_coefficient == 0; + } + + void rewrote_row(int64_t max_coef_before, int64_t max_coef_after) + { + row_shrinks.push_back((double)max_coef_before / max_coef_after); + } + + void report() + { + if (coefficients_reduced == 0) return; + const size_t n_rows_rewritten = row_shrinks.size(); + cuopt_assert(n_rows_rewritten > 0, "a changed coefficient implies an accepted row"); + const double mean = + std::accumulate(row_shrinks.begin(), row_shrinks.end(), 0.0) / n_rows_rewritten; + // One row collapsing to magnitude 1 dominates the mean, so report the median next to it. + const auto middle = row_shrinks.begin() + n_rows_rewritten / 2; + std::nth_element(row_shrinks.begin(), middle, row_shrinks.end()); + double median = *middle; + if (n_rows_rewritten % 2 == 0) + median = (median + *std::max_element(row_shrinks.begin(), middle)) / 2.0; + + CUOPT_LOG_DEBUG( + "BHW reduced %ld coefficients (%ld dropped) in %zu rows, " + "max|a| shrank %.1fx mean, %.1fx median", + coefficients_reduced, + coefficients_dropped, + n_rows_rewritten, + mean, + median); + } +#else + void changed_coefficient(int64_t) {} + void rewrote_row(int64_t, int64_t) {} + void report() {} +#endif +}; + +} // namespace + +template +papilo::PresolveStatus BHWCoeffReduce::execute(const papilo::Problem& problem, + const papilo::ProblemUpdate& problemUpdate, + const papilo::Num& num, + papilo::Reductions& reductions, + const papilo::Timer& timer, + int& reason_of_infeasibility) +{ + const auto& constraint_matrix = problem.getConstraintMatrix(); + const auto& lhs_values = constraint_matrix.getLeftHandSides(); + const auto& rhs_values = constraint_matrix.getRightHandSides(); + const auto& row_flags = constraint_matrix.getRowFlags(); + const auto& domains = problem.getVariableDomains(); + const auto& col_flags = domains.flags; + const auto& lower_bounds = domains.lower_bounds; + const auto& upper_bounds = domains.upper_bounds; + const auto& presolve_options = problemUpdate.getPresolveOptions(); + + const int num_rows = constraint_matrix.getNRows(); + papilo::PresolveStatus status = papilo::PresolveStatus::kUnchanged; + bhw_stats_t stats; + + // Every eligible row is screened, not only problemUpdate.getChangedActivities(): that worklist is + // seeded in full once and afterwards fed only by activity changes, so a row whose side changes is + // never revisited. Rescreening is cheap because the shape cache absorbs the repetition and the + // reduction is idempotent. + for (int row = 0; row < num_rows; ++row) { + if (reductions.size() >= presolve_options.max_reduction_seq) break; + if (row % BHW_INTERRUPT_CHECK_STRIDE == 0 && + papilo::PresolveMethod::is_interrupted( + timer, presolve_options.tlim, presolve_options.early_exit_callback)) + break; + + auto row_coefficients = constraint_matrix.getRowCoefficients(row); + const int len = row_coefficients.getLength(); + if (len < 2 || len > BHW_MAX_LEN) continue; + + const auto& row_flag = row_flags[row]; + if (row_flag.test(papilo::RowFlag::kRedundant)) continue; + const bool lhs_infinite = row_flag.test(papilo::RowFlag::kLhsInf); + const bool rhs_infinite = row_flag.test(papilo::RowFlag::kRhsInf); + // Equal flags mean either a ranged row / equation (both sides finite) or a free row. + if (lhs_infinite == rhs_infinite) continue; + + const int* indices = row_coefficients.getIndices(); + const f_t* values = row_coefficients.getValues(); + bool all_binary = true; + for (int j = 0; j < len && all_binary; ++j) { + const int col = indices[j]; + all_binary = col_flags[col].test(papilo::ColFlag::kIntegral) && + !col_flags[col].test(papilo::ColFlag::kLbInf) && + !col_flags[col].test(papilo::ColFlag::kUbInf) && + !col_flags[col].test(papilo::ColFlag::kFixed) && num.isZero(lower_bounds[col]) && + num.isEq(upper_bounds[col], f_t{1}); + } + if (!all_binary) continue; + + const int direction = lhs_infinite ? 1 : -1; + const f_t side = lhs_infinite ? rhs_values[row] : lhs_values[row]; + const bhw_row_rewrite_t rewrite = + bhw_reduce_row(values, len, side, direction, &shape_cache_); + if (!rewrite.accepted) continue; + + cuopt_assert(rewrite.max_coef_after >= 1, + "an accepted rewrite keeps at least one nonzero weight"); + stats.rewrote_row(rewrite.max_coef_before, rewrite.max_coef_after); + + // Same shape as papilo's own sparsifier, SimplifyInequalities: lock the row, then the entries, + // then the side. Dropping an entry needs no column lock: ProblemUpdate marks the column + // modified and derives the resulting singleton rows and empty columns itself. The zero has to + // be exact: SparseStorage::changeRowInplace compacts an entry out on newval == 0, not on + // num.isZero. + papilo::TransactionGuard guard{reductions}; + reductions.lockRow(row); + [[maybe_unused]] int emitted = 0; + for (int j = 0; j < len; ++j) { + if ((f_t)rewrite.coefficients[j] == values[j]) continue; + reductions.changeMatrixEntry(row, indices[j], (f_t)rewrite.coefficients[j]); + ++emitted; + stats.changed_coefficient(rewrite.coefficients[j]); + } + if (direction == 1) { + if ((f_t)rewrite.side != rhs_values[row]) { + reductions.changeRowRHS(row, (f_t)rewrite.side); + ++emitted; + } + } else { + if ((f_t)rewrite.side != lhs_values[row]) { + reductions.changeRowLHS(row, (f_t)rewrite.side); + ++emitted; + } + } + // Reporting kReduced for a transaction that changes nothing would have papilo re-derive the + // same rewrite every round. + cuopt_assert(emitted > 0, "accepted rewrite emitted no reduction"); + status = papilo::PresolveStatus::kReduced; + } + + stats.report(); + + return status; +} + +#define INSTANTIATE(F_TYPE) \ + template class BHWCoeffReduce; \ + template bhw_row_rewrite_t bhw_reduce_row( \ + const F_TYPE*, int, F_TYPE, int, bhw_shape_cache_t*); + +#if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT +INSTANTIATE(float) +#endif + +#if MIP_INSTANTIATE_DOUBLE +INSTANTIATE(double) +#endif + +#undef INSTANTIATE + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp new file mode 100644 index 0000000000..fee1d27334 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/bhw_coeff_reduce.hpp @@ -0,0 +1,101 @@ +/* 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 + +#if !defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wstringop-overflow" // ignore boost error for pip wheel build +#pragma GCC diagnostic ignored "-Wnarrowing" +#endif +#include +#include +#include +#include +#if !defined(__clang__) +#pragma GCC diagnostic pop +#endif + +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// Widest row we enumerate: building the point partition walks 2^BHW_MAX_LEN patterns, so this caps +// the cost of screening one row. Wider rows are left alone; nearly every row that integerizes +// exactly is narrower than this. +static constexpr int BHW_MAX_LEN = 12; +// Largest max|w| the exhaustive search considers before falling back to the heuristic candidates. +// Reductions needing a larger magnitude are rare enough not to pay for the extra search depth. +static constexpr int64_t BHW_EXACT_MAX_WEIGHT = 6; +// Largest per-row rational multiplier / denominator used to integerize a row (passed to +// row_int_scale as its maxdnom/maxfinal caps). +static constexpr int64_t BHW_INT_SCALE_MAX = 1000000; // 1e6 + +// Outcome for one canonical row shape, in BHW's normalized frame (coefficients complemented to be +// positive and sorted descending). Rejections are cached too, since re-deriving them is the bulk of +// the work on instances whose rows repeat. +struct bhw_shape_result_t { + std::vector weights; + int64_t bound = 0; + bool accepted = false; +}; + +// Keyed by the normalized coefficients followed by the normalized right-hand side. The reduction is +// a pure function of that key, so entries stay valid across presolve rounds and problems. +using bhw_shape_cache_t = std::map, bhw_shape_result_t>; + +struct bhw_row_rewrite_t { + std::vector coefficients; // one per input entry, in input order; 0 drops that entry + int64_t side = 0; // replaces the row's finite side + // Largest |coefficient| before and after reduction, both in the integerized frame. Comparable + // only there: the row is scaled on the way in, so the input coefficients sit in a different + // frame. + int64_t max_coef_before = 0; + int64_t max_coef_after = 0; + bool accepted = false; +}; + +// Rewrite one one-sided all-binary row with smaller integer coefficients spanning the same 0/1 +// feasible set. direction is +1 for "coefficients . x <= side" and -1 for ">= side"; side is the +// finite side of the row. Rejects the row (accepted = false) unless it integerizes exactly to +// nonzero coefficients, admits a strictly smaller equivalent form, and that form does not enlarge +// the row's LP relaxation. cache may be null to skip memoization. +// +// The caller checks that every entry is a binary integer variable and that exactly one side of the +// row is finite. Exposed for testing: BHWCoeffReduce::execute only screens rows and emits the +// result, so this covers the whole reduction without any papilo types. +template +bhw_row_rewrite_t bhw_reduce_row( + const f_t* coefficients, int len, f_t side, int direction, bhw_shape_cache_t* cache); + +// Bradley-Hammer-Wolsey coefficient reduction: replace an all-binary row by an equivalent one with +// smaller integer coefficients. See bhw_coeff_reduce.cpp for the lineage. +template +class BHWCoeffReduce : public papilo::PresolveMethod { + public: + BHWCoeffReduce() : papilo::PresolveMethod() + { + this->setName("bhwcoeffreduce"); + this->setType(papilo::PresolverType::kIntegralCols); + this->setTiming(papilo::PresolverTiming::kMedium); + } + + papilo::PresolveStatus execute(const papilo::Problem& problem, + const papilo::ProblemUpdate& problemUpdate, + const papilo::Num& num, + papilo::Reductions& reductions, + const papilo::Timer& timer, + int& reason_of_infeasibility) override; + + private: + // Only touched from execute, which papilo runs one task at a time per presolver object. + bhw_shape_cache_t shape_cache_; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/block_bve.cu b/cpp/src/mip_heuristics/presolve/block_bve.cu new file mode 100644 index 0000000000..6874833e15 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/block_bve.cu @@ -0,0 +1,1553 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "block_bve.cuh" +#include "trivial_presolve.cuh" + +#include +#include +#include + +#include + +#include +#include + +#include + +#include + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +static constexpr int BVE_MAX_INTERIOR = BVE_MAX_SCOPE - 1; +// Cap closure probes over high-degree implication neighborhoods. +static constexpr int BVE_MAX_GROWTH_NBRS = 256; +// Cap peak device allocation for each projection chunk. +static constexpr size_t BVE_PROJECT_DEVICE_BUDGET = 64ull << 20; // 64 MiB +// Cap the enumeration cost of one projection batch, summed as 2^(na+nb) * nnz over the candidates +// accepted into it. +static constexpr double BVE_BATCH_PROJECTION_BUDGET = 1e8; +static constexpr int BVE_MIN_COMMIT_RATIO = 20; +// Outer rounds of the phase: each re-derives the implication graph from the model the previous one +// left behind. +static constexpr int BVE_MAX_ROUNDS = 3; +// Share of the model's columns a round must retire for another round's detect pass to be worth +// running. +static constexpr double BVE_MIN_ROUND_YIELD = 0.01; +// Seconds for the whole phase: implication graph build plus every round. Install and compact finish +// the round already committed, so a phase can exceed this by that tail. +static constexpr double BVE_STAGE_TIME_LIMIT = 1.5; + +// Largest per-row rational multiplier / denominator we will apply. A row that would need a larger +// multiplier to become integer is treated as not exactly representable +static constexpr int64_t BVE_INT_SCALE_MAX = 1e6; + +// Closed-form part of the commit_projected work estimate: prime-cube enumeration in +// bve_greedy_prime_cover is Θ(nb · 3^nb); sanity check is Θ(2^nb · #clauses) with #clauses bounded +// by the growth gate (n_rows + clause_growth_margin). +static double bve_commit_wall_ops(int nb, int clause_budget) +{ + cuopt_assert(nb >= 0 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + double three_nb = 1.0; + for (int i = 0; i < nb; ++i) + three_nb *= 3.0; + return nb * three_nb + (double)(1 << nb) * (clause_budget + 1); +} + +bool bve_sanity_check(const uint8_t* feas, int nb, const bve_clause_t* clauses, int n_clauses) +{ + const uint32_t full_mask = (1u << nb) - 1u; + for (int i = 0; i < n_clauses; ++i) + if (clauses[i].lit_mask & ~full_mask) return false; // literals must be on the boundary + for (uint32_t m = 0; m <= full_mask; ++m) { + bool crel = true; // CNF value: AND over clauses of (clause satisfied by pattern m) + for (int i = 0; i < n_clauses && crel; ++i) { + const uint32_t lit = clauses[i].lit_mask; + const uint32_t bit = clauses[i].bit_mask; + // clause satisfied iff some literal position differs from its forbidden bit under m + const bool satisfied = ((m ^ bit) & lit) != 0u; + if (!satisfied) crel = false; + } + const bool feasible = feas[m] != 0; + if (crel != feasible) return false; + } + return true; +} + +// =========================================================================================== +// Installed CNF: all prime forbidden cubes covered by max-gain greedy +// =========================================================================================== +// +// Two-level logic minimization in the shape of Quine, "The Problem of Simplifying Truth Functions" +// (Amer. Math. Monthly 1952) and McCluskey, "Minimization of Boolean Functions" (Bell System Tech. +// J. 1956): enumerate the prime implicants, then cover every minterm with a subset of them. Taking +// the primes of the infeasible patterns rather than the feasible ones makes each one a forbidden +// cube whose complement is a clause, so the cover comes out as a CNF instead of the usual DNF. +// +// The covering step is the greedy max-gain heuristic of Johnson, "Approximation Algorithms for +// Combinatorial Problems" (JCSS 1974), Lovász (Discrete Math. 1975) and Chvátal (Math. of OR 1979): +// repeatedly take the cube covering the most still-uncovered patterns, which lands within a factor +// 1 + ln m of the minimum cover for m infeasible patterns. + +static size_t bve_mask_words(int nb) { return ((1u << nb) + 63u) / 64u; } + +static int bve_mask_size(const bve_mask_t& m) +{ + int n = 0; + for (uint64_t w : m) + n += std::popcount(w); + return n; +} + +static void bve_mask_set(bve_mask_t& m, uint32_t pattern) +{ + cuopt_assert(size_t(pattern >> 6) < m.size(), "pattern outside mask width"); + m[pattern >> 6] |= uint64_t{1} << (pattern & 63); +} + +static void bve_mask_subtract(bve_mask_t& m, const bve_mask_t& other) +{ + cuopt_assert(m.size() == other.size(), "mask width mismatch"); + for (size_t w = 0; w < m.size(); ++w) + m[w] &= ~other[w]; +} + +static int bve_mask_overlap(const bve_mask_t& a, const bve_mask_t& b) +{ + cuopt_assert(a.size() == b.size(), "mask width mismatch"); + int n = 0; + for (size_t w = 0; w < a.size(); ++w) + n += std::popcount(a[w] & b[w]); + return n; +} + +// valid(lit, bit): every boundary pattern matching cube (lit, bit) is infeasible, so the +// complementary clause excludes no feasible pattern. Adding a literal SHRINKS the cube, so the +// table is filled from the minterms (lit == full_mask) downward in literal count: +// valid(lit, bit) = valid(lit|j, bit) AND valid(lit|j, bit|j) for any j not in lit +// A cube is already the bve_clause_t (lit_mask, bit_mask) encoding, so no separate ternary cube +// code is needed. The dense table is 4^nb bytes (16 MiB at nb = 12), so `valid` is caller-owned and +// grown once rather than reallocated per block. It is never re-initialized: only cells with +// bit subset of lit are ever addressed, the minterm seeding plus the recurrence below write every +// such cell, and each pass reads only cells an earlier pass already wrote. +static void bve_enumerate_prime_cubes(const uint8_t* feas, + int nb, + std::vector& valid, + std::vector& primes) +{ + cuopt_assert(nb >= 1 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + const uint32_t full_mask = (1u << nb) - 1u; + const size_t stride = size_t(full_mask) + 1; + if (valid.size() < stride * stride) valid.resize(stride * stride); + const auto at = [&](uint32_t lit, uint32_t bit) -> uint8_t& { + cuopt_assert((bit & ~lit) == 0u, "cube bit_mask outside its lit_mask"); + return valid[size_t(lit) * stride + bit]; + }; + + for (uint32_t m = 0; m <= full_mask; ++m) + at(full_mask, m) = feas[m] ? 0 : 1; + + for (int n_lits = nb - 1; n_lits >= 0; --n_lits) + for (uint32_t lit = 0; lit <= full_mask; ++lit) { + if (std::popcount(lit) != n_lits) continue; + const int j = std::countr_zero(~lit & full_mask); + const uint32_t child = lit | (1u << j); + for (uint32_t bit = lit;; bit = (bit - 1u) & lit) { + at(lit, bit) = at(child, bit) & at(child, bit | (1u << j)); + if (bit == 0u) break; + } + } + + primes.clear(); + for (uint32_t lit = 0; lit <= full_mask; ++lit) + for (uint32_t bit = lit;; bit = (bit - 1u) & lit) { + if (at(lit, bit)) { + bool prime = true; + for (int j = 0; j < nb && prime; ++j) + if ((lit & (1u << j)) != 0u && at(lit ^ (1u << j), bit & ~(1u << j))) prime = false; + if (prime) primes.push_back(bve_clause_t{lit, bit}); + } + if (bit == 0u) break; + } +} + +// Boundary patterns matching the cube; every one of them is infeasible when the cube is valid. +static void bve_cube_cover( + uint32_t lit, uint32_t bit, uint32_t full_mask, size_t n_words, bve_mask_t& cover) +{ + cover.assign(n_words, 0u); + const uint32_t free_positions = full_mask & ~lit; + for (uint32_t s = free_positions;; s = (s - 1u) & free_positions) { + bve_mask_set(cover, bit | s); + if (s == 0u) break; + } +} + +int bve_greedy_prime_cover(const uint8_t* feas, + int nb, + bve_clause_t* out, + int cap, + bve_cover_scratch_t& scratch, + int64_t* ops_out) +{ + cuopt_assert(nb >= 1 && nb <= BVE_MAX_BOUNDARY, "nb out of BVE range"); + cuopt_assert(cap >= 1, "clause cap leaves no room for a cover"); + int64_t ops = 0; + auto ops_guard = cuopt::scope_guard([&]() { + if (ops_out != nullptr) *ops_out += ops; + }); + + const uint32_t full_mask = (1u << nb) - 1u; + const uint32_t n_patterns = 1u << nb; + const size_t n_words = bve_mask_words(nb); + + bve_enumerate_prime_cubes(feas, nb, scratch.valid, scratch.primes); + const std::vector& primes = scratch.primes; + + bve_mask_t& uncovered = scratch.uncovered; + uncovered.assign(n_words, 0u); + for (uint32_t m = 0; m < n_patterns; ++m) + if (!feas[m]) bve_mask_set(uncovered, m); + if (bve_mask_size(uncovered) == 0) return 0; // nothing to forbid + cuopt_assert(!primes.empty(), "infeasible patterns exist but no prime cube was enumerated"); + + scratch.cover.resize(primes.size()); + for (size_t q = 0; q < primes.size(); ++q) { + bve_cube_cover(primes[q].lit_mask, primes[q].bit_mask, full_mask, n_words, scratch.cover[q]); + // Zeroing the words, then one set-bit per pattern the cube matches. + ops += n_words + (int64_t{1} << (nb - std::popcount(primes[q].lit_mask))); + } + + int n = 0; + while (bve_mask_size(uncovered) > 0) { + // Per pick: the size test above, one bve_mask_overlap per prime, then the subtract below. + ops += (primes.size() + 2) * n_words; + int best_q = -1; + int best_gain = 0; + for (size_t q = 0; q < primes.size(); ++q) { + const int gain = bve_mask_overlap(uncovered, scratch.cover[q]); + if (gain > best_gain) { + best_gain = gain; + best_q = q; + } + } + cuopt_assert(best_q >= 0, "prime cubes do not cover the infeasible patterns"); + if (n >= cap) return -1; + out[n++] = primes[best_q]; + bve_mask_subtract(uncovered, scratch.cover[best_q]); + } + ops += n_words; // the size test that ended the loop + cuopt_assert(n >= 1, "non-empty infeasible set covered by zero clauses"); + return n; +} + +// Committed elimination in commit order. `witness[pattern]` packs interior values for the boundary +// pattern; reductions are replayed in reverse order during postsolve. +template +struct bve_reduction_t { + std::vector interior; + std::vector boundary; + std::vector witness; // size 2^boundary.size() +}; + +// A surviving clause row to append to problem_t (a set-covering no-good over boundary columns). +// Always in >= form. +template +struct bve_added_row_t { + std::vector> terms; + f_t lower; +}; + +template +struct bve_plan_t { + std::vector> reductions; // commit order + std::vector removed_rows; // original row ids to drop + std::vector> added_rows; // surviving clause rows +}; + +// Working model and accumulated reduction plan. Candidates are staged without mutation and +// committed only after projection and clause validation. +template +struct bve_reducer_t { + struct work_row_t { + std::vector> terms; + f_t lo, up; + bool active; + }; + + i_t n_vars, n_rows_orig; + f_t tol; + i_t boundary_cap, scope_cap, clause_growth_margin; + std::vector rows; + std::vector> col2rows; + std::vector is_bin, obj_nz, done; + bve_plan_t plan; + bve_cover_scratch_t cover_scratch; + + bve_reducer_t(i_t n_vars_, + i_t n_rows_orig_, + const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& col_lower, + const std::vector& col_upper, + const std::vector& is_integer, + const std::vector& obj, + f_t tol_, + i_t boundary_cap_, + i_t scope_cap_, + i_t clause_growth_margin_); + + // Rows spanned by `interior` and the boundary columns of those rows, both unsorted, with op + // accounting. Single traversal behind both the growth probe (which needs only the boundary size) + // and stage(); outputs are overwritten, so a caller in a loop can reuse them. + void scope_of(const std::vector& interior, + std::vector& rows_out, + std::vector& boundary_out, + int64_t& ops) const; + + // Gather and pack a candidate without projecting or mutating the working model. + bool stage(const std::vector& interior_in, + bve_candidate_t& out, + int64_t* ops_out = nullptr); + + // Validate and commit an already-projected candidate; return true iff reduced. + bool commit_projected(const bve_candidate_t& cand, int64_t* ops_out = nullptr); + + bve_plan_t finalize(); +}; + +template +bve_reducer_t::bve_reducer_t(i_t n_vars_, + i_t n_rows_orig_, + const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& col_lower, + const std::vector& col_upper, + const std::vector& is_integer, + const std::vector& obj, + f_t tol_, + i_t boundary_cap_, + i_t scope_cap_, + i_t clause_growth_margin_) + : n_vars(n_vars_), + n_rows_orig(n_rows_orig_), + tol(tol_), + boundary_cap(boundary_cap_), + scope_cap(scope_cap_), + clause_growth_margin(clause_growth_margin_), + col2rows(n_vars_), + is_bin(n_vars_), + obj_nz(n_vars_), + done(n_vars_, 0) +{ + const f_t INF = std::numeric_limits::infinity(); + for (i_t c = 0; c < n_vars; ++c) { + is_bin[c] = + (is_integer[c] && std::abs(col_lower[c]) < tol && std::abs(col_upper[c] - f_t(1)) < tol) ? 1 + : 0; + obj_nz[c] = (obj[c] != f_t(0)) ? 1 : 0; + } + rows.reserve(n_rows_orig * 2); + for (i_t r = 0; r < n_rows_orig; ++r) { + work_row_t R; + R.active = true; + R.lo = scaling_bound_finite(row_lower[r]) ? row_lower[r] : -INF; + R.up = scaling_bound_finite(row_upper[r]) ? row_upper[r] : INF; + for (i_t k = offsets[r]; k < offsets[r + 1]; ++k) + R.terms.emplace_back(variables[k], coefficients[k]); + i_t id = rows.size(); + rows.push_back(std::move(R)); + for (auto& p : rows[id].terms) + col2rows[p.first].insert(id); + } +} + +template +void bve_reducer_t::scope_of(const std::vector& interior, + std::vector& rows_out, + std::vector& boundary_out, + int64_t& ops) const +{ + ops += interior.size(); + std::unordered_set interior_set(interior.begin(), interior.end()); + std::unordered_set affected_rows; + for (i_t a : interior) + for (i_t r : col2rows[a]) { + ++ops; + affected_rows.insert(r); + } + std::unordered_set b; + for (i_t r : affected_rows) + for (const auto& p : rows[r].terms) { + ++ops; + if (!interior_set.count(p.first)) b.insert(p.first); + } + rows_out.assign(affected_rows.begin(), affected_rows.end()); + boundary_out.assign(b.begin(), b.end()); +} + +// Rescale every row to integer coefficients and bounds so the projection can run at tolerance 0. +// Returns false if any row does not scale to bounded integers. +template +static bool integerize_projection_rows(bve_block_t& block) +{ + for (int rr = 0; rr < block.n_rows; ++rr) { + const int rb = block.row_off[rr]; + const int re = block.row_off[rr + 1]; + const double s = row_int_scale(block.row_coef + rb, + re - rb, + block.row_lo[rr], + block.row_up[rr], + BVE_MAX_ROW_LEN, + BVE_INT_SCALE_MAX); + if (s == 0.0) return false; + for (int k = rb; k < re; ++k) + block.row_coef[k] = std::llround((double)block.row_coef[k] * s); + if (scaling_bound_finite(block.row_lo[rr])) + block.row_lo[rr] = std::llround((double)block.row_lo[rr] * s); + if (scaling_bound_finite(block.row_up[rr])) + block.row_up[rr] = std::llround((double)block.row_up[rr] * s); + } + return true; +} + +template +bool bve_reducer_t::stage(const std::vector& interior_in, + bve_candidate_t& out, + int64_t* ops_out) +{ + int64_t ops = 0; + auto ops_guard = cuopt::scope_guard([&]() { + if (ops_out != nullptr) *ops_out += ops; + }); + + std::vector interior(interior_in.begin(), interior_in.end()); + std::sort(interior.begin(), interior.end()); + std::vector affected_rows, boundary; + scope_of(interior, affected_rows, boundary, ops); + // sorting improves GPU shape-binning + std::sort(affected_rows.begin(), affected_rows.end()); + ops += affected_rows.size(); + std::sort(boundary.begin(), boundary.end()); + ops += boundary.size(); + + const i_t nb = boundary.size(); + const i_t na = interior.size(); + if (nb == 0 || nb > boundary_cap || na + nb > scope_cap) return false; + for (i_t v : boundary) + if (!is_bin[v]) return false; + if (na > BVE_MAX_INTERIOR || nb > BVE_MAX_BOUNDARY || na + nb > BVE_MAX_SCOPE) return false; + if (affected_rows.size() > BVE_MAX_ROWS) return false; + + bve_block_t& blk = out.blk; + blk.na = na; + blk.nb = nb; + blk.n_rows = affected_rows.size(); + std::unordered_map local; + for (i_t j = 0; j < na; ++j) + local[interior[j]] = j; + for (i_t j = 0; j < nb; ++j) + local[boundary[j]] = na + j; + ops += na + nb; + i_t nzc = 0; + bool row_overflow = false; + for (i_t rr = 0; rr < blk.n_rows && !row_overflow; ++rr) { + const i_t r = affected_rows[rr]; + blk.row_off[rr] = nzc; + if (rows[r].terms.size() > BVE_MAX_ROW_LEN || nzc + rows[r].terms.size() > BVE_MAX_NNZ) { + row_overflow = true; + break; + } + for (auto& p : rows[r].terms) { + blk.row_var[nzc] = local[p.first]; + blk.row_coef[nzc] = p.second; + ++nzc; + ++ops; + } + blk.row_lo[rr] = rows[r].lo; + blk.row_up[rr] = rows[r].up; + } + if (row_overflow) return false; + blk.row_off[blk.n_rows] = nzc; + + if (!integerize_projection_rows(blk)) return false; + + out.interior = std::move(interior); + out.boundary = std::move(boundary); + out.rows = std::move(affected_rows); + out.projection.feasible.assign(size_t(1) << nb, 0); + out.projection.witness.assign(size_t(1) << nb, 0u); + ops += 1 << nb; + return true; +} + +template +bool bve_reducer_t::commit_projected(const bve_candidate_t& cand, + int64_t* ops_out) +{ + const int nb = cand.blk.nb; + const uint8_t* feasible = cand.projection.feasible.data(); + cuopt_assert(cand.projection.feasible.size() == (size_t(1) << nb), "projection table unsized"); + bve_clause_t clauses[BVE_MAX_CLAUSES]; + const int n_clauses = + bve_greedy_prime_cover(feasible, nb, clauses, BVE_MAX_CLAUSES, cover_scratch, ops_out); + if (n_clauses < 0) return false; // clause explosion past cap + if (n_clauses > cand.blk.n_rows + clause_growth_margin) return false; // growth gate + if (!bve_sanity_check(feasible, nb, clauses, n_clauses)) + return false; // sanity check failed => keep block + + bve_reduction_t red; + red.interior = cand.interior; + red.boundary = cand.boundary; + red.witness = cand.projection.witness; + plan.reductions.push_back(std::move(red)); + + for (i_t r : cand.rows) { + for (auto& p : rows[r].terms) + col2rows[p.first].erase(r); + rows[r].active = false; + rows[r].terms.clear(); + } + const f_t INF = std::numeric_limits::infinity(); + for (i_t ci = 0; ci < n_clauses; ++ci) { + const uint32_t lit = clauses[ci].lit_mask; + const uint32_t bit = clauses[ci].bit_mask; + cuopt_assert(lit != 0u, "empty clause reached the row builder"); + work_row_t R; + R.active = true; + R.up = INF; + i_t n1 = 0; + for (i_t j = 0; j < nb; ++j) + if (lit & (1u << j)) { + const i_t b = (bit >> j) & 1u; + R.terms.emplace_back(cand.boundary[j], b ? f_t(-1) : f_t(1)); + n1 += b; + } + R.lo = f_t(1 - n1); + i_t id = rows.size(); + rows.push_back(std::move(R)); + for (auto& p : rows[id].terms) + col2rows[p.first].insert(id); + } + for (i_t a : cand.interior) { + col2rows[a].clear(); + done[a] = 1; + } + return true; +} + +template +bve_plan_t bve_reducer_t::finalize() +{ + for (i_t r = 0; r < n_rows_orig; ++r) + if (!rows[r].active) plan.removed_rows.push_back(r); + for (size_t r = n_rows_orig; r < rows.size(); ++r) + if (rows[r].active) { + cuopt_assert(rows[r].up == std::numeric_limits::infinity(), + "clause rows carry no upper bound"); + bve_added_row_t ar; + ar.terms = std::move(rows[r].terms); + ar.lower = rows[r].lo; + plan.added_rows.push_back(std::move(ar)); + } + return plan; +} + +// =========================================================================================== +// GPU enumeration projection kernel +// =========================================================================================== + +// +// grid : one CTA per assignment (block, boundary pattern m, interior pattern am), +// grid-strided over CTAs ( for assignment = blockIdx.x; ...; += gridDim.x ) +// CTA : one warp per row ( blockDim.x == min(nrows,32)*32; warps loop if nrows > 32 ) +// warp : reduces sum = Σ coeff * value over the row's entries, tests sum in [lower, upper] +// +// The CTA ANDs the per-row satisfied bits into a single "assignment feasible" bit. For each +// boundary pattern m, feasibility is the OR over its interior patterns am and the witness is the +// first feasible am; both are encoded by a single atomicMin into `out_witness` (sentinel 0xFFFFFFFF +// = no feasible interior), so downstream: +// feasible[block][m] == (out_witness[block][m] != 0xFFFFFFFF) +// witness [block][m] == out_witness[block][m] // the smallest feasible interior +// `out_witness` must be initialized to 0xFFFFFFFF by the caller before launch. + +template +__global__ void bve_enumerate_kernel( + i_t num_blocks, + i_t nb, + i_t na, + i_t nrows, + f_t tolerance, + const f_t* block_coeffs, // [num_blocks * nnz] + const i_t* local_var_of_entry, // [nnz] (shared by the bin) + const i_t* row_start, // [nrows + 1] (shared by the bin) + const f_t* block_row_lower, // [num_blocks * nrows] + const f_t* block_row_upper, // [num_blocks * nrows] + uint32_t* out_witness) // [num_blocks * (1<> (na + nb); + + const f_t* coeffs = block_coeffs + block * nnz; + const f_t* lower = block_row_lower + block * nrows; + const f_t* upper = block_row_upper + block * nrows; + + for (i_t row = warp_id; row < nrows; row += num_warps) { + f_t partial = 0; + for (i_t entry = row_start[row] + lane_id; entry < row_start[row + 1]; entry += 32) { + const i_t var = local_var_of_entry[entry]; + const f_t value = + (var < na) ? ((interior_pattern >> var) & 1) : ((boundary_pattern >> (var - na)) & 1); + partial += coeffs[entry] * value; + } + const f_t sum = raft::warpReduce(partial); + if (lane_id == 0) { + row_satisfied[row] = + (sum <= upper[row] + tolerance && sum >= lower[row] - tolerance) ? 1 : 0; + } + } + __syncthreads(); + + // AND the per-row bits; if this assignment is feasible, offer its interior as a witness + if (threadIdx.x == 0) { + uint8_t feasible = 1; + for (i_t row = 0; row < nrows; ++row) { + feasible &= row_satisfied[row]; + } + if (feasible) { + atomicMin(&out_witness[block * num_patterns + boundary_pattern], + (uint32_t)interior_pattern); + } + } + __syncthreads(); + } +} + +// ---- GPU batch projection: one enumeration-kernel launch per shape-bin ---- +// Returns raw work for the enumerations (sum over bins of assignments · nnz). +template +double bve_project_batch_gpu(const raft::handle_t& handle, + std::vector>& cands, + f_t tol, + const timer_t& timer) +{ + if (cands.empty()) return 0.0; + auto stream = handle.get_stream(); + double work_units = 0.0; + + // Bin candidates by identical shape so every CTA in a launch runs the same loop structure. + struct shape_key_hash { + size_t operator()(const std::vector& key) const + { + size_t h = 0; + for (i_t x : key) { + h ^= std::hash{}(x) + 0x9e3779b9 + (h << 6) + (h >> 2); + } + return h; + } + }; + std::unordered_map, std::vector, shape_key_hash> bins; + for (size_t i = 0; i < cands.size(); ++i) { + const auto& blk = cands[i].blk; + const i_t nnz = blk.row_off[blk.n_rows]; + std::vector key; + key.reserve(4 + (blk.n_rows + 1) + nnz); + key.push_back(blk.na); + key.push_back(blk.nb); + key.push_back(blk.n_rows); + key.push_back(nnz); + for (i_t r = 0; r <= blk.n_rows; ++r) + key.push_back(blk.row_off[r]); + for (i_t k = 0; k < nnz; ++k) + key.push_back(blk.row_var[k]); + bins[std::move(key)].push_back(i); + } + + for (const auto& kv : bins) { + if (timer.check_time_limit()) return work_units; + const std::vector& idxs = kv.second; + const auto& proto = cands[idxs[0]].blk; + const i_t na = proto.na; + const i_t nb = proto.nb; + const i_t nrows = proto.n_rows; + const i_t nnz = proto.row_off[nrows]; + const i_t patterns = i_t(1) << nb; + + // Shared layout is O(nnz) and identical for every candidate in the bin. + std::vector h_row_start(proto.row_off, proto.row_off + nrows + 1); + std::vector h_local_var(proto.row_var, proto.row_var + nnz); + rmm::device_uvector d_row_start(h_row_start.size(), stream); + rmm::device_uvector d_local_var(h_local_var.size(), stream); + raft::copy(d_row_start.data(), h_row_start.data(), h_row_start.size(), stream); + raft::copy(d_local_var.data(), h_local_var.data(), h_local_var.size(), stream); + + // Per-block device cost: coeffs + row bounds + witness table. + const size_t bytes_per_block = size_t(nnz) * sizeof(f_t) + 2 * size_t(nrows) * sizeof(f_t) + + size_t(patterns) * sizeof(uint32_t); + + const size_t chunk = + std::max(1, + std::min(size_t(std::numeric_limits::max()), + BVE_PROJECT_DEVICE_BUDGET / std::max(1, bytes_per_block))); + + const int num_warps = std::min(nrows, 32); + const int cta_dim = num_warps * 32; + const size_t shmem = size_t(nrows) * sizeof(uint8_t); + + for (size_t offset = 0; offset < idxs.size(); offset += chunk) { + if (timer.check_time_limit()) return work_units; + const size_t num_sz = std::min(chunk, idxs.size() - offset); + const i_t num = num_sz; + + std::vector h_coeffs(num_sz * size_t(nnz)); + std::vector h_lower(num_sz * size_t(nrows)); + std::vector h_upper(num_sz * size_t(nrows)); + for (size_t g = 0; g < num_sz; ++g) { + const auto& blk = cands[idxs[offset + g]].blk; + std::copy(blk.row_coef, blk.row_coef + nnz, h_coeffs.begin() + g * nnz); + std::copy(blk.row_lo, blk.row_lo + nrows, h_lower.begin() + g * nrows); + std::copy(blk.row_up, blk.row_up + nrows, h_upper.begin() + g * nrows); + } + + rmm::device_uvector d_coeffs(h_coeffs.size(), stream); + rmm::device_uvector d_lower(h_lower.size(), stream); + rmm::device_uvector d_upper(h_upper.size(), stream); + rmm::device_uvector d_witness(num_sz * size_t(patterns), stream); + raft::copy(d_coeffs.data(), h_coeffs.data(), h_coeffs.size(), stream); + raft::copy(d_lower.data(), h_lower.data(), h_lower.size(), stream); + raft::copy(d_upper.data(), h_upper.data(), h_upper.size(), stream); + // sentinel 0xFFFFFFFF (every byte 0xFF) marks a boundary pattern with no feasible interior + // yet + RAFT_CUDA_TRY( + cudaMemsetAsync(d_witness.data(), 0xFF, d_witness.size() * sizeof(uint32_t), stream)); + + // one warp per row, one CTA per (block, m, am) assignment, grid-strided + const int64_t total = (int64_t)num * (int64_t)patterns * ((int64_t)1 << na); + const int grid = std::min(total, int64_t{65535}); + bve_enumerate_kernel<<>>(num, + nb, + na, + nrows, + tol, + d_coeffs.data(), + d_local_var.data(), + d_row_start.data(), + d_lower.data(), + d_upper.data(), + d_witness.data()); + RAFT_CUDA_TRY(cudaGetLastError()); + + // Unscaled op counts: host pack/unpack touches + one coeff read per assignment. + work_units += (double)num_sz * (nnz + 2 * nrows + patterns); + work_units += (double)total * nnz; + + std::vector h_witness(num_sz * size_t(patterns)); + raft::copy(h_witness.data(), d_witness.data(), h_witness.size(), stream); + handle.sync_stream(); + for (size_t g = 0; g < num_sz; ++g) { + auto& cand = cands[idxs[offset + g]]; + // No-op for anything stage() produced; sizes a caller that assembled `blk` by hand. + cand.projection.feasible.resize(patterns); + cand.projection.witness.resize(patterns); + for (i_t m = 0; m < patterns; ++m) { + const uint32_t w = h_witness[g * patterns + m]; + const bool feasible = (w != 0xFFFFFFFFu); + cand.projection.feasible[m] = feasible ? 1 : 0; + cand.projection.witness[m] = feasible ? w : 0u; + } + cand.projection.projected = true; + } + } + } + return work_units; +} + +// ---- harvest unary-conditioned implications from an exactly projected block ---- +// +// `feas` is the block's exact existential projection onto its nb boundary columns, so for boundary +// position j and value a the feasible patterns agreeing with (j == a) describe every completion the +// block admits. Intersecting them (AND) gives the positions forced to 1 and the complement of their +// union (OR) gives those forced to 0; the same reasoning with no condition gives unconditional +// fixings. This is complete for the block's rows, where the probing cache only holds what bound +// propagation could prove, so these forcings can be strictly stronger. It holds whether or not the +// block is eventually eliminated, hence the call site harvests before the growth gate can reject. +// +// Ids are emitted in the current-problem frame; the caller maps them to original ids. +template +static void bve_extract_forcings(const bve_candidate_t& cand, probe_findings_t& out) +{ + const i_t nb = cand.blk.nb; + cuopt_assert(nb > 0 && nb <= BVE_MAX_BOUNDARY, "boundary width out of range"); + cuopt_assert((i_t)cand.boundary.size() == nb, "boundary id count disagrees with block width"); + const uint32_t n_patterns = 1u << nb; + + // Accumulators for condition s = 2*j + a; slot 2*nb holds the unconditional case. + constexpr i_t n_slots = 2 * BVE_MAX_BOUNDARY + 1; + const i_t unconditional = 2 * nb; + const uint32_t all_ones = n_patterns - 1u; + uint32_t and_acc[n_slots]; + uint32_t or_acc[n_slots]; + std::fill_n(and_acc, unconditional + 1, all_ones); + std::fill_n(or_acc, unconditional + 1, 0u); + + uint32_t n_feasible = 0; + for (uint32_t m = 0; m < n_patterns; ++m) { + if (!cand.projection.feasible[m]) continue; + ++n_feasible; + and_acc[unconditional] &= m; + or_acc[unconditional] |= m; + for (i_t j = 0; j < nb; ++j) { + const i_t s = 2 * j + ((m >> j) & 1u); + and_acc[s] &= m; + or_acc[s] |= m; + } + } + // Vacuous accumulators would otherwise read as "every position forced to 1". + if (n_feasible == 0u) return; // the caller turns this block into an infeasibility proof + + // Positions the block fixes outright. + const uint32_t fixed_mask = and_acc[unconditional] | (~or_acc[unconditional] & all_ones); + for (i_t j = 0; j < nb; ++j) { + if (!(fixed_mask & (1u << j))) continue; + out.fixings.emplace_back(cand.boundary[j], ((and_acc[unconditional] >> j) & 1u) != 0u); + } + + for (i_t j = 0; j < nb; ++j) { + if (fixed_mask & (1u << j)) continue; // condition never binds + for (i_t a = 0; a < 2; ++a) { + const i_t s = 2 * j + a; + for (i_t k = 0; k < nb; ++k) { + const uint32_t bit = 1u << k; + if (k == j || (fixed_mask & bit)) continue; + if (and_acc[s] & bit) { + out.forcings.push_back({cand.boundary[j], cand.boundary[k], a != 0, true}); + } else if (!(or_acc[s] & bit)) { + out.forcings.push_back({cand.boundary[j], cand.boundary[k], a != 0, false}); + } + } + } + } +} + +template +struct bve_growth_result_t { + std::vector interior; // sorted current-problem column ids, always contains the seed + int64_t ops = 0; // work performed, for the deterministic wall estimate +}; + +// Grows one seed into a block interior: starting from {seed}, repeatedly absorb the eligible +// implication-neighbor that shrinks the boundary the most, stopping when no neighbor strictly +// improves it or a cap is hit. Read-only on `reducer`, which is what lets the round run this across +// seeds under OpenMP against a frozen model. +template +static bve_growth_result_t grow_seed_interior( + i_t seed, + const bve_reducer_t& reducer, + const std::vector>& implication_adjacency, + const timer_t& timer) +{ + auto has_adj = [&](i_t v) { + return v >= 0 && v < (i_t)implication_adjacency.size() && !implication_adjacency[v].empty(); + }; + + bve_growth_result_t result; + std::unordered_set interior_set = {seed}; + std::vector probe_rows, probe_bnd; // scope_of scratch, reused across probes + bool timed_out = false; + for (;;) { + if (timer.check_time_limit()) break; + // Hub fast-path: raw implication degree upper-bounds |cands_w|. + if (interior_set.size() == 1) { + const i_t s = *interior_set.begin(); + const i_t deg = has_adj(s) ? (i_t)implication_adjacency[s].size() : 0; + if (deg > BVE_MAX_GROWTH_NBRS) break; + } + std::vector candidate_interior(interior_set.begin(), interior_set.end()); + reducer.scope_of(candidate_interior, probe_rows, probe_bnd, result.ops); + const i_t cur = probe_bnd.size(); + // Implication-neighbors of the interior that are still eligible to enter it. + std::unordered_set cands_w; + bool gated = false; + for (i_t a : interior_set) { + if (!has_adj(a)) continue; + for (i_t w : implication_adjacency[a]) { + ++result.ops; + const bool eligible = reducer.is_bin[w] && !reducer.obj_nz[w] && !reducer.done[w] && + !reducer.col2rows[w].empty(); + if (interior_set.count(w) || !eligible) continue; + cands_w.insert(w); + if ((i_t)cands_w.size() > BVE_MAX_GROWTH_NBRS) { + gated = true; + break; + } + } + if (gated) break; + } + // Hub neighborhoods: full probe is Θ(|cands_w|) boundary walks and rarely absorbs. + if (gated) break; + // Pick the neighbor with the smallest boundary + i_t best = -1; + i_t best_nb = cur; + i_t probes = 0; + for (i_t w : cands_w) { + // Each probe is a boundary walk, so the deadline is honoured within a single seed's growth. + if ((++probes & 0xF) == 0 && timer.check_time_limit()) { + timed_out = true; + break; + } + candidate_interior.push_back(w); // probe interior ∪ {w}; the pop below restores it + const i_t na = candidate_interior.size(); + reducer.scope_of(candidate_interior, probe_rows, probe_bnd, result.ops); + const i_t nb = probe_bnd.size(); + candidate_interior.pop_back(); + if (nb < best_nb && na + nb <= reducer.scope_cap && na <= BVE_MAX_INTERIOR) { + best_nb = nb; + best = w; + } + } + if (timed_out || best < 0) break; + interior_set.insert(best); + } + result.interior.assign(interior_set.begin(), interior_set.end()); + return result; +} + +// Implication-closure block growth over the probing-cache adjacency: each seed absorbs the +// implication-neighbor that most shrinks its boundary (subject to enum/interior caps) until no +// such neighbor remains. Within a round the working model is frozen, so every seed grows its +// interior against the same model. Because that growth is read-only on the model, it runs in an +// OpenMP parallel-for across the round's seeds; the results are deterministic per seed and +// acceptance is then applied serially in seed order, so the committed plan is identical to a serial +// run of the same frozen growth. Candidates are staged and only mutually scope-disjoint ones (no +// shared interior or boundary column, which also forbids a shared row) are accepted into the batch. +// The batch is projected on the device (bve_project_batch_gpu), then committed on the host; because +// the accepted candidates touch disjoint columns/rows, commit order is irrelevant and each block's +// staged projection is still valid at commit time. Candidates deferred for overlap are retried in +// later rounds; the loop stops when a round accepts nothing or commits nothing (each committing +// round retires >= 1 column, hence terminates). +// +// A block whose projection admits no boundary assignment sets `out_infeasible` and abandons the +// round with an empty plan, so nothing this call staged is ever installed. +template +static bve_plan_t bve_detect_closure_batched( + const raft::handle_t& handle, + bve_reducer_t& reducer, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* findings, + bool* out_infeasible) +{ + std::vector order; + for (i_t c = 0; c < reducer.n_vars; ++c) { + // grow_seed_interior's hub fast path refuses to grow a seed whose implication degree is past + // the probe cap, so such a seed only ever reaches stage() as a singleton interior. + const i_t degree = c < (i_t)impl_adj.size() ? (i_t)impl_adj[c].size() : 0; + const bool growable = degree > 0 && degree <= BVE_MAX_GROWTH_NBRS; + if (reducer.is_bin[c] && !reducer.obj_nz[c] && !reducer.col2rows[c].empty() && growable) + order.push_back(c); + } + std::sort(order.begin(), order.end(), [&](i_t a, i_t b) { + return reducer.col2rows[a].size() < reducer.col2rows[b].size(); + }); + + std::vector attempted(reducer.n_vars, 0); + std::vector growth_done(reducer.n_vars, 0); + std::vector> growth_interior(reducer.n_vars); + for (;;) { + if (timer.check_time_limit()) break; + + // This round's live seeds, in the deterministic growth order. + std::vector round_seeds; + for (i_t seed : order) + if (!attempted[seed] && !reducer.done[seed] && !reducer.col2rows[seed].empty()) + round_seeds.push_back(seed); + if (round_seeds.empty()) break; + + // Grow each seed against the frozen model (read-only on reducer → OMP-safe). Acceptance below + // is serial in round_seeds order, so the plan matches a serial frozen-growth run. + std::vector> interiors(round_seeds.size()); + std::vector growth_ops(round_seeds.size(), 0); +#pragma omp taskloop default(shared) priority(CUOPT_DEFAULT_TASK_PRIORITY) + for (i_t k = 0; k < (i_t)round_seeds.size(); ++k) { + const i_t seed = round_seeds[k]; + if (growth_done[seed]) { + interiors[k] = growth_interior[seed]; + continue; + } + bve_growth_result_t grown = grow_seed_interior(seed, reducer, impl_adj, timer); + growth_ops[k] = grown.ops; + interiors[k] = std::move(grown.interior); + growth_interior[seed] = interiors[k]; + growth_done[seed] = 1; + } + // OMP growth: wall ≈ critical-path seed (max), not sum across threads. + int64_t max_growth_ops = 0; + for (int64_t ops : growth_ops) + max_growth_ops = std::max(max_growth_ops, ops); + work_units += max_growth_ops; + + if (timer.check_time_limit()) break; + + // Serial: stage each grown interior and greedily accept mutually SCOPE-DISJOINT candidates, in + // round_seeds order. Nothing mutates the model until commit, so this stays serial. + std::vector> cands; + std::unordered_set claimed; // interior+boundary columns of already-accepted candidates + double batch_projection_ops = 0.0; + for (size_t k = 0; k < round_seeds.size(); ++k) { + if (timer.check_time_limit()) break; + const i_t seed = round_seeds[k]; + bve_candidate_t cand; + int64_t stage_ops = 0; + if (!reducer.stage(interiors[k], cand, &stage_ops)) { + work_units += stage_ops; + attempted[seed] = 1; // failed the caps against this model; one attempt per seed + continue; + } + work_units += stage_ops; + bool overlap = false; + for (i_t c : cand.interior) + if (claimed.count(c)) { + overlap = true; + break; + } + if (!overlap) + for (i_t c : cand.boundary) + if (claimed.count(c)) { + overlap = true; + break; + } + if (overlap) continue; // scope collides; retry stage later from cached interior + + attempted[seed] = 1; + for (i_t c : cand.interior) + claimed.insert(c); + for (i_t c : cand.boundary) + claimed.insert(c); + cuopt_assert(cand.blk.na + cand.blk.nb <= BVE_MAX_SCOPE, "staged scope past enumeration cap"); + batch_projection_ops += + (double)(1u << (cand.blk.na + cand.blk.nb)) * cand.blk.row_off[cand.blk.n_rows]; + cands.push_back(std::move(cand)); + + if (batch_projection_ops >= BVE_BATCH_PROJECTION_BUDGET) break; + } + + if (cands.empty() || timer.check_time_limit()) break; + // Staged blocks are integerized (integerize_projection_rows), so the subset-sum feasibility + // test is exact at tolerance 0. + work_units += bve_project_batch_gpu(handle, cands, f_t(0), timer); + if (timer.check_time_limit()) break; + i_t committed = 0; + for (auto& cand : cands) { + if (timer.check_time_limit()) break; + cuopt_assert(cand.projection.projected, "commit loop reached an unprojected candidate"); + const bool admits_nothing = + cand.projection.projected && std::none_of(cand.projection.feasible.begin(), + cand.projection.feasible.end(), + [](uint8_t feasible) { return feasible != 0; }); + if (admits_nothing) { + if (out_infeasible != nullptr) *out_infeasible = true; + return {}; + } + // Valid for the block's rows regardless of the clause gates below, so harvest before them. + if (findings != nullptr) { + bve_extract_forcings(cand, *findings); + work_units += (double)(1u << cand.blk.nb) * cand.blk.nb; + } + work_units += + bve_commit_wall_ops(cand.blk.nb, cand.blk.n_rows + reducer.clause_growth_margin); + int64_t commit_ops = 0; + if (reducer.commit_projected(cand, &commit_ops)) ++committed; + work_units += commit_ops; + } + if (committed == 0) break; + cuopt_assert(committed <= (i_t)cands.size(), "committed more candidates than were projected"); + if ((i_t)cands.size() > committed * BVE_MIN_COMMIT_RATIO) break; + } + return reducer.finalize(); +} + +// ---- implication adjacency from the probing cache (original-id -> current column) ---- +template +std::vector> bve_build_impl_adj( + const probing_cache_t& cache, + const std::vector& reverse_original_ids, + i_t n_vars, + const timer_t& timer, + const probe_findings_t* prior_original_id_findings) +{ + // original-id -> current column index (or -1 if the column no longer exists) + auto to_current = [&](i_t original_id) -> i_t { + if (original_id < 0 || original_id >= (i_t)reverse_original_ids.size()) return -1; + return reverse_original_ids[original_id]; + }; + std::vector> adj(n_vars); + auto add_edge = [&](i_t original_x, i_t original_y) { + const i_t x = to_current(original_x); + if (x < 0 || x >= n_vars) return; + const i_t y = to_current(original_y); + if (y < 0 || y >= n_vars || y == x) return; + adj[x].insert(y); + adj[y].insert(x); + }; + // An abandoned build returns no edges rather than a partial graph, so which reductions exist + // never depends on where the clock landed. + i_t entries_seen = 0; + for (const auto& kv : cache.probing_cache) { + if ((++entries_seen & 0x3F) == 0 && timer.check_time_limit()) { + return std::vector>(n_vars); + } + for (int p = 0; p < 2; ++p) { + for (const auto& yb : kv.second[p].var_to_cached_bound_map) + add_edge(kv.first, yb.first); + } + } + // Forcings mined from earlier projections. Pairs the cache never held become seed/absorb + // candidates, so a later round can grow blocks the first round could not see. + if (prior_original_id_findings != nullptr) { + i_t forcings_seen = 0; + for (const auto& forcing : prior_original_id_findings->forcings) { + if ((++forcings_seen & 0x3FF) == 0 && timer.check_time_limit()) { + return std::vector>(n_vars); + } + add_edge(forcing.var, forcing.forced_var); + } + } + std::vector> out(n_vars); + for (i_t v = 0; v < n_vars; ++v) + out[v].assign(adj[v].begin(), adj[v].end()); + return out; +} + +template +static void append_bve_reconstructions(const bve_plan_t& plan, + const std::vector& current_to_post_papilo, + presolve_data_t& presolve_data, + double& work_units) +{ + auto to_post_papilo = [&](i_t column) { + cuopt_assert(column >= 0 && column < (i_t)current_to_post_papilo.size(), + "block column out of variable_mapping range"); + return current_to_post_papilo[column]; + }; + + auto& reconstructions = presolve_data.postsolve_reconstructions; + reconstructions.reserve(reconstructions.size() + plan.reductions.size()); + for (const auto& red : plan.reductions) { + work_units += red.interior.size() + red.boundary.size() + red.witness.size(); + postsolve_reconstruction_t reconstruction; + reconstruction.kind = reconstruction_kind_t::BlockBve; + reconstruction.bve.interior.reserve(red.interior.size()); + for (i_t c : red.interior) + reconstruction.bve.interior.push_back(to_post_papilo(c)); + reconstruction.bve.boundary.reserve(red.boundary.size()); + for (i_t c : red.boundary) + reconstruction.bve.boundary.push_back(to_post_papilo(c)); + reconstruction.bve.witness = red.witness; + reconstructions.push_back(std::move(reconstruction)); + } +} + +template +bool bve_has_stageable_row(const problem_t& problem) +{ + const i_t n_rows = problem.n_constraints; + if (problem.empty || n_rows == 0) { return false; } + const i_t* offsets = problem.offsets.data(); + constexpr i_t max_len = BVE_MAX_ROW_LEN; + return thrust::any_of(problem.handle_ptr->get_thrust_policy(), + thrust::make_counting_iterator(0), + thrust::make_counting_iterator(n_rows), + [offsets, max_len] __device__(i_t r) -> bool { + return offsets[r + 1] - offsets[r] <= max_len; + }); +} + +// Pin variables that a BVE projection table showed to have a single admissible value. Ids arrive in +// the original frame and may repeat across blocks and rounds. Returns false when two blocks +// disagree on a variable, which proves infeasibility since each fixing is a consequence of its +// block alone. +template +static bool apply_bve_fixings(problem_t& problem, + const std::vector>& fixings, + i_t& n_applied) +{ + n_applied = 0; + if (fixings.empty()) { return true; } + std::vector> sorted(fixings); + std::sort(sorted.begin(), sorted.end()); + + const std::vector& reverse_original_ids = problem.reverse_original_ids; + std::vector var_indices; + std::vector lb_values; + std::vector ub_values; + for (size_t k = 0; k < sorted.size(); ++k) { + const auto [original_id, value] = sorted[k]; + if (k > 0 && original_id == sorted[k - 1].first) { + if (value != sorted[k - 1].second) { return false; } + continue; + } + cuopt_assert(original_id >= 0 && original_id < (i_t)reverse_original_ids.size(), + "fixings are keyed by original id"); + const i_t column = reverse_original_ids[original_id]; + if (column < 0 || column >= problem.n_variables) { continue; } // already eliminated + var_indices.push_back(column); + lb_values.push_back(value ? f_t(1) : f_t(0)); + ub_values.push_back(value ? f_t(1) : f_t(0)); + } + n_applied = var_indices.size(); + problem.update_variable_bounds(var_indices, lb_values, ub_values); + return true; +} + +// ---- the pass: detect (GPU-projected) -> install reduced model -> record reconstructions ---- +template +bool block_bve_presolve(problem_t& problem, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* out_findings, + bool* out_infeasible, + i_t boundary_cap, + i_t scope_cap, + i_t clause_growth_margin) +{ + work_units = 0.0; + timer_t wall(std::numeric_limits::infinity()); + [[maybe_unused]] double t_setup = 0.0, t_detect = 0.0, t_install = 0.0, t_compact = 0.0; + auto timer_raii_guard = cuopt::scope_guard([&]() { + CUOPT_LOG_DEBUG( + "Block-BVE phases: setup=%.2fs detect=%.2fs install=%.2fs compact=%.2fs total=%.2fs " + "work units: %.6g", + t_setup, + t_detect, + t_install, + t_compact, + wall.elapsed_time(), + work_units); + }); + + const raft::handle_t* handle = problem.handle_ptr; + auto stream = handle->get_stream(); + const i_t n_vars = problem.n_variables; + const i_t n_rows = problem.n_constraints; + const f_t tol = problem.tolerances.presolve_absolute_tolerance; + if (problem.empty || n_vars == 0 || n_rows == 0) return false; + + auto h_off = cuopt::host_copy(problem.offsets, stream); + auto h_var = cuopt::host_copy(problem.variables, stream); + auto h_coef = cuopt::host_copy(problem.coefficients, stream); + auto h_clb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + auto h_cub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + auto h_vb = cuopt::host_copy(problem.variable_bounds, stream); + auto h_vtype = cuopt::host_copy(problem.variable_types, stream); + auto h_obj = cuopt::host_copy(problem.objective_coefficients, stream); + auto h_vmap = cuopt::host_copy(problem.presolve_data.variable_mapping, stream); + handle->sync_stream(); + + const i_t nnz0 = h_off.back(); + work_units = 2.0 * nnz0 + 2.0 * n_vars + n_rows; + + if (timer.check_time_limit()) return false; + + std::vector offsets(h_off.begin(), h_off.end()); + std::vector variables(h_var.begin(), h_var.end()); + std::vector coefficients(h_coef.begin(), h_coef.end()); + std::vector row_lower(h_clb.begin(), h_clb.end()); + std::vector row_upper(h_cub.begin(), h_cub.end()); + std::vector col_lower(n_vars), col_upper(n_vars); + std::vector is_integer(n_vars); + for (i_t c = 0; c < n_vars; ++c) { + col_lower[c] = get_lower(h_vb[c]); + col_upper[c] = get_upper(h_vb[c]); + is_integer[c] = (h_vtype[c] == var_t::INTEGER) ? 1 : 0; + } + std::vector obj(h_obj.begin(), h_obj.end()); + + if (timer.check_time_limit()) return false; // the reducer below walks the CSR again + + // ---- detect + sanity check (probing-cache implication closure). Projection of each candidate + // block runs on the GPU: the batched detector stages scope-disjoint candidates per round and + // hands the whole batch to bve_project_batch_gpu (one enumeration-kernel launch per shape-bin), + // which fills feas/witness; commit (prime-implicate CNF + inline sanity check) then runs on the + // host. ---- + bve_reducer_t reducer(n_vars, + n_rows, + offsets, + variables, + coefficients, + row_lower, + row_upper, + col_lower, + col_upper, + is_integer, + obj, + tol, + boundary_cap, + scope_cap, + clause_growth_margin); + t_setup = wall.elapsed_time(); + probe_findings_t current_id_findings; + bool detected_infeasible = false; + bve_plan_t plan = + bve_detect_closure_batched(*handle, + reducer, + impl_adj, + timer, + work_units, + out_findings != nullptr ? ¤t_id_findings : nullptr, + &detected_infeasible); + t_detect = wall.elapsed_time() - t_setup; + + if (detected_infeasible) { + cuopt_assert(plan.reductions.empty(), "an infeasibility proof must abandon the round's plan"); + if (out_infeasible != nullptr) *out_infeasible = true; + return false; + } + + // Projection findings hold for the block's rows whether or not the block was eliminated, so they + // are exported before the no-reduction exit; the rejected blocks are often the interesting ones. + if (out_findings != nullptr) { + auto to_original = [&](i_t column) { + cuopt_assert(column >= 0 && column < (i_t)h_vmap.size(), "column outside variable_mapping"); + return (i_t)h_vmap[column]; + }; + out_findings->forcings.reserve(out_findings->forcings.size() + + current_id_findings.forcings.size()); + for (const auto& forcing : current_id_findings.forcings) { + out_findings->forcings.push_back({to_original(forcing.var), + to_original(forcing.forced_var), + forcing.value, + forcing.forced_value}); + } + for (const auto& [column, value] : current_id_findings.fixings) + out_findings->fixings.emplace_back(to_original(column), value); + } + + if (plan.reductions.empty()) return false; + + // ---- build the reduced forward CSR, append clause rows ---- + const double t_install_begin = wall.elapsed_time(); + std::vector removed(n_rows, 0); + for (i_t r : plan.removed_rows) + removed[r] = 1; + std::vector new_off, new_var; + std::vector new_coef, new_clb, new_cub; + new_off.reserve(n_rows + plan.added_rows.size() + 1); + new_off.push_back(0); + for (i_t r = 0; r < n_rows; ++r) { + if (removed[r]) continue; + for (i_t k = offsets[r]; k < offsets[r + 1]; ++k) { + new_var.push_back(variables[k]); + new_coef.push_back(coefficients[k]); + } + new_off.push_back(new_var.size()); + new_clb.push_back(row_lower[r]); + new_cub.push_back(row_upper[r]); + } + for (const auto& ar : plan.added_rows) { + cuopt_assert(!ar.terms.empty(), "installing a term-free row loses whatever it constrained"); + for (const auto& [var, coef] : ar.terms) { + new_var.push_back(var); + new_coef.push_back(coef); + } + new_off.push_back(new_var.size()); + new_clb.push_back(ar.lower); // eliminated interior cols become empty (only in removed rows) + // clause rows are >= no-goods; upper is +inf (problem_t convention) + new_cub.push_back(std::numeric_limits::infinity()); + } + + work_units += new_var.size() + new_clb.size(); + problem.set_constraints_from_host_csr(new_off, new_var, new_coef, new_clb, new_cub, {}); + + append_bve_reconstructions(plan, h_vmap, problem.presolve_data, work_units); + t_install = wall.elapsed_time() - t_install_begin; + + const double t_compact_begin = wall.elapsed_time(); + work_units += n_vars + new_var.size(); + trivial_presolve(problem, /*remap_cache_ids=*/true); + handle->sync_stream(); + t_compact = wall.elapsed_time() - t_compact_begin; + const i_t reduced_cols = n_vars - problem.n_variables; + const i_t reduced_rows = n_rows - problem.n_constraints; + if (reduced_cols > 0 || reduced_rows > 0) { + CUOPT_LOG_DEBUG("Block-BVE reduced %d columns, %d rows", reduced_cols, reduced_rows); + } +#if (CUOPT_LOG_ACTIVE_LEVEL <= RAPIDS_LOGGER_LOG_LEVEL_DEBUG) + const i_t fractional_coefs = + thrust::count_if(handle->get_thrust_policy(), + problem.coefficients.begin(), + problem.coefficients.end(), + [] __device__(f_t v) -> bool { return floor(v) != v; }); + CUOPT_LOG_DEBUG("Block-BVE: %d fractional coefficients in A", fractional_coefs); +#endif + return true; +} + +template +bool block_bve_phase(bound_presolve_t& bound_presolve, + problem_t& problem, + const timer_t& deadline) +{ + if (const char* disabled = std::getenv("CUOPT_DISABLE_BLOCK_BVE"); + disabled != nullptr && std::atoi(disabled) != 0) { + CUOPT_LOG_DEBUG("Block-BVE disabled via CUOPT_DISABLE_BLOCK_BVE"); + return true; + } + + if (bound_presolve.probing_cache.probing_cache.empty()) { + CUOPT_LOG_DEBUG("Block-BVE skipped: the probing cache is empty"); + return true; + } + + const i_t n_vars_before_phase = problem.n_variables; + const i_t n_rows_before_phase = problem.n_constraints; + + // Implications read off the projection tables, accumulated across rounds. They feed the next + // round's adjacency (pairs the cache never held) and are folded back into the cache afterwards. + probe_findings_t findings; + timer_t stage_timer(deadline.clamp_remaining_time(BVE_STAGE_TIME_LIMIT)); + for (i_t round = 0; round < BVE_MAX_ROUNDS; ++round) { + if (problem.empty || deadline.check_time_limit() || stage_timer.check_time_limit()) { break; } + + if (!bve_has_stageable_row(problem)) { + CUOPT_LOG_DEBUG("Block-BVE skipped: every row exceeds the %d-nonzero block row cap", + BVE_MAX_ROW_LEN); + break; + } + + const i_t n_vars_before = problem.n_variables; + const i_t n_rows_before = problem.n_constraints; + auto impl_adj = bve_build_impl_adj(bound_presolve.probing_cache, + problem.reverse_original_ids, + problem.n_variables, + stage_timer, + &findings); + if (stage_timer.check_time_limit()) { + CUOPT_LOG_DEBUG("Block-BVE hit its %.2fs phase limit building the implication graph", + stage_timer.get_time_limit()); + break; + } + + double work_units = 0.0; + bool proved_infeasible = false; + timer_t round_timer(stage_timer.clamp_remaining_time(deadline.remaining_time())); + const bool reduced = + block_bve_presolve(problem, impl_adj, round_timer, work_units, &findings, &proved_infeasible); + if (proved_infeasible) { + CUOPT_LOG_DEBUG("Block-BVE proved the problem infeasible"); + return false; + } + CUOPT_LOG_DEBUG("Block-BVE outer round %d/%d: reduced=%d vars %d->%d rows %d->%d", + round + 1, + BVE_MAX_ROUNDS, + (int)reduced, + n_vars_before, + problem.n_variables, + n_rows_before, + problem.n_constraints); + if (!reduced) { break; } + if (problem.n_variables >= n_vars_before) { break; } + if (n_vars_before - problem.n_variables < n_vars_before * BVE_MIN_ROUND_YIELD) { break; } + } + + // Harvest the projections: tighten the cache in place, pin the variables the blocks left with a + // single value, then propagate. + bound_presolve.probing_cache.merge_forcings(findings.forcings, findings.fixings); + i_t n_fixings = 0; + if (!deadline.check_time_limit()) { + if (!apply_bve_fixings(problem, findings.fixings, n_fixings)) { return false; } + if (n_fixings > 0) { trivial_presolve(problem, /*remap_cache_ids=*/true); } + } + const bool changed_model = problem.n_variables != n_vars_before_phase || + problem.n_constraints != n_rows_before_phase || n_fixings > 0; + if (changed_model) { + CUOPT_LOG_DEBUG("Block-BVE projections fixed %d variables", n_fixings); + if (!problem.empty && !deadline.check_time_limit()) { + bound_presolve.resize(problem); + auto term_crit = bound_presolve.solve(problem); + if (bound_presolve.infeas_constraints_count > 0) { return false; } + if (termination_criterion_t::NO_UPDATE != term_crit) { + bound_presolve.set_updated_bounds(problem); + } + } + } + return true; +} + +#define INSTANTIATE(F_TYPE) \ + template double bve_project_batch_gpu( \ + const raft::handle_t&, std::vector>&, F_TYPE, const timer_t&); \ + template std::vector> bve_build_impl_adj( \ + const probing_cache_t&, \ + const std::vector&, \ + int, \ + const timer_t&, \ + const probe_findings_t*); \ + template bool bve_has_stageable_row(const problem_t&); \ + template bool block_bve_phase( \ + bound_presolve_t&, problem_t&, const timer_t&); \ + template bool block_bve_presolve(problem_t&, \ + const std::vector>&, \ + timer_t&, \ + double&, \ + probe_findings_t*, \ + bool*, \ + int, \ + int, \ + int) + +#if MIP_INSTANTIATE_FLOAT +INSTANTIATE(float); +#endif + +#if MIP_INSTANTIATE_DOUBLE +INSTANTIATE(double); +#endif + +#undef INSTANTIATE + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/block_bve.cuh b/cpp/src/mip_heuristics/presolve/block_bve.cuh new file mode 100644 index 0000000000..08bb3b91f0 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/block_bve.cuh @@ -0,0 +1,167 @@ +/* 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 "bounds_presolve.cuh" +#include "probing_cache.cuh" + +#include + +#include +#include + +// Eliminates small blocks of zero-objective binary variables. A block is a set of columns to remove +// (the interior, na columns) together with every row they appear in; the other columns of those +// rows are the boundary (nb columns), which stays in the model and must also be binary. +// +// For each of the 2^nb boundary assignments the projection decides whether some interior +// assignment satisfies the block's rows. The ruled-out assignments are everything the block still +// forces on the rest of the model, so emitting them as prime-implicate no-goods over the boundary +// carries that force without the interior. Committing therefore deletes the interior columns and +// every block row, installing the no-goods in their place: interior variables disappear and the row +// count drops whenever the no-goods are fewer than the rows they replace, which the growth gate +// below requires. One feasible interior witness per surviving assignment is stored so postsolve +// can rebuild the deleted columns; since the interior carries no objective coefficients, any +// witness preserves the objective as well as feasibility. +// +// Candidate interiors are grown from the probing implication graph and committed only when the +// projected CNF satisfies the bounded-elimination growth limit of Eén and Biere, "Effective +// Preprocessing in SAT through Variable and Clause Elimination" (SAT 2005). Before commit, the +// emitted clauses are checked against the GPU-computed boundary feasibility table. + +namespace cuopt::mathematical_optimization::mip { + +// Caps for a single enumerated block. +static constexpr int BVE_MAX_BOUNDARY = 12; // nb <= 12 => 2^nb <= 4096 feasibility patterns +static constexpr int BVE_MAX_SCOPE = 16; // na + nb <= 16 +static constexpr int BVE_MAX_ROWS = 64; // rows spanned by the block; #clauses <= #rows +static constexpr int BVE_MAX_ROW_LEN = 24; // nnz within one block row (interior+boundary entries) +static constexpr int BVE_MAX_NNZ = BVE_MAX_ROWS * BVE_MAX_ROW_LEN; +static constexpr int BVE_MAX_CLAUSES = 64; // <= |rows| for any committed block +static constexpr int BVE_MAX_PATTERNS = 1 << BVE_MAX_BOUNDARY; + +// Packed projection block. Local ids [0, na) are interior and [na, na+nb) are boundary; rows use +// CSR layout and missing bounds are +/- infinity. +template +struct bve_block_t { + int na; // number of interior variables + int nb; // number of boundary variables + int n_rows; // rows spanned by the block + int row_off[BVE_MAX_ROWS + 1]; + int row_var[BVE_MAX_NNZ]; // local var id in [0, na+nb) + f_t row_coef[BVE_MAX_NNZ]; + f_t row_lo[BVE_MAX_ROWS]; // -inf if no lower bound + f_t row_up[BVE_MAX_ROWS]; // +inf if no upper bound +}; + +// Boundary clause forbidding patterns that match `bit_mask` at every position in `lit_mask`. +// It is emitted as sum_j (bit_j == 0 ? x_j : -x_j) >= 1 - popcount(bit_mask & lit_mask). +struct bve_clause_t { + uint32_t lit_mask; + uint32_t bit_mask; +}; + +// One bit per boundary pattern. The width tracks the block's own 2^nb, not BVE_MAX_PATTERNS, so +// raising BVE_MAX_BOUNDARY costs nothing on narrower blocks. +using bve_mask_t = std::vector; + +// Buffers the CNF construction reuses across blocks: `valid` alone is 4^nb bytes, so per-block +// allocation would dominate at wide boundaries. +struct bve_cover_scratch_t { + std::vector valid; // prime-cube validity table, grow-only + std::vector primes; + std::vector cover; // patterns matched by each prime + bve_mask_t uncovered; +}; + +// Derive a prime-implicate CNF from the boundary feasibility table by covering the infeasible +// patterns with a max-gain greedy over every prime forbidden cube; return -1 on cap overflow. +// Untemplated: the CNF is a Boolean computation over the feasibility table, and every dimension it +// touches is capped by the BVE_MAX_* constants above. +int bve_greedy_prime_cover(const uint8_t* feas, + int nb, + bve_clause_t* out, + int cap, + bve_cover_scratch_t& scratch, + int64_t* ops_out = nullptr); + +// Verify that the emitted clauses reproduce the boundary feasibility table exactly. +bool bve_sanity_check(const uint8_t* feas, int nb, const bve_clause_t* clauses, int n_clauses); + +// Exact existential projection of one block onto its boundary, filled by the projection backend. +// Both tables are sized to the block's own 2^nb rather than BVE_MAX_PATTERNS, so a narrow block +// does not carry the cost of raising BVE_MAX_BOUNDARY. +struct bve_projection_t { + std::vector feasible; // [2^nb] 1 iff the boundary pattern admits some interior + std::vector witness; // [2^nb] smallest feasible interior, 0 where infeasible + bool projected{false}; // set by the backend once both tables hold its result +}; + +// Staged candidate. Vector fields use sorted current-problem ids; `blk` uses local ids. +template +struct bve_candidate_t { + std::vector interior; // sorted global column ids (to be eliminated) + std::vector boundary; // sorted global column ids (kept) + std::vector rows; // sorted global row ids spanned by the block + bve_block_t blk; // gathered block, local ids, for the projection + bve_projection_t projection; // sized and zeroed by stage(), filled by the projection backend +}; + +// Project shape-binned candidate batches on the GPU and return a deterministic work estimate. +template +double bve_project_batch_gpu(const raft::handle_t& handle, + std::vector>& cands, + f_t tol, + const timer_t& timer); + +// Build symmetric current-problem implication adjacency from the original-id keyed probing cache, +// optionally unioned with forcings harvested from earlier block projections (also original-id). +// Returns an edgeless adjacency when `timer` expires, which leaves the pass with no seeds. +template +std::vector> bve_build_impl_adj( + const probing_cache_t& cache, + const std::vector& reverse_original_ids, + i_t n_vars, + const timer_t& timer, + const probe_findings_t* prior_original_id_findings = nullptr); + +// True when some row is short enough to appear in a staged block. A block's scope spans whole rows, +// so a model whose every row exceeds BVE_MAX_ROW_LEN has no stageable candidate no matter what the +// implication graph holds, and the pass can be skipped before that graph is built. +template +bool bve_has_stageable_row(const problem_t& problem); + +// cuOpt's block-BVE presolve phase, and the only entry point production code needs: bounded rounds +// of detect and install against the probing implication graph, followed by the projection harvest +// (cache tightening, single-value fixings, bound propagation). Mutates `problem` in place and +// returns false when the phase proved it infeasible. Requires a populated probing cache in +// `bound_presolve`; the caller decides whether the phase runs at all. +template +bool block_bve_phase(bound_presolve_t& bound_presolve, + problem_t& problem, + const timer_t& deadline); + +// Run block BVE using caller-provided implication adjacency and deadline. Returns true iff at least +// one validated reduction was installed; `work_units` receives a deterministic unscaled estimate. +// `out_findings`, when given, is appended with the implications read off every projected block +// (original-id frame), including blocks that were not eliminated. +template +bool block_bve_presolve(problem_t& problem, + const std::vector>& impl_adj, + timer_t& timer, + double& work_units, + probe_findings_t* out_findings = nullptr, + bool* out_infeasible = nullptr, + i_t boundary_cap = BVE_MAX_BOUNDARY, + i_t scope_cap = BVE_MAX_SCOPE, + i_t clause_growth_margin = 0); + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cu b/cpp/src/mip_heuristics/presolve/probing_cache.cu index 38d076581f..d331f27f80 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cu +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cu @@ -162,10 +162,15 @@ void inline insert_current_probing_to_cache(i_t var_idx, const std::vector& modified_lb, const std::vector& modified_ub, const std::vector& h_integer_indices, + const std::vector& original_ids, std::atomic& n_implied_singletons) { f_t int_tol = bound_presolve.context.settings.tolerances.integrality_tolerance; + cuopt_assert(var_idx >= 0 && var_idx < (i_t)original_ids.size(), + "probe var out of original_ids range"); + const i_t var_original = original_ids[var_idx]; + cache_entry_t cache_item; cache_item.val_interval = probe_val; for (auto impacted_var_idx : h_integer_indices) { @@ -180,18 +185,21 @@ void inline insert_current_probing_to_cache(i_t var_idx, "Lower bound must be greater than or equal to original lower bound"); cuopt_assert(modified_ub[impacted_var_idx] <= get_upper(original_var_bounds), "Upper bound must be less than or equal to original upper bound"); + cuopt_assert(impacted_var_idx >= 0 && impacted_var_idx < (i_t)original_ids.size(), + "impacted var out of original_ids range"); cached_bound_t new_bound{modified_lb[impacted_var_idx], modified_ub[impacted_var_idx]}; - cache_item.var_to_cached_bound_map.insert({impacted_var_idx, new_bound}); + // Map keys are original-frame ids (same frame as reverse_original_ids / bve_build_impl_adj). + cache_item.var_to_cached_bound_map.insert({original_ids[impacted_var_idx], new_bound}); } } { std::lock_guard lock(bound_presolve.probing_cache.probing_cache_mutex); - if (!bound_presolve.probing_cache.probing_cache.count(var_idx) > 0) { + if (!bound_presolve.probing_cache.probing_cache.count(var_original) > 0) { std::array, 2> entries_per_var; entries_per_var[0] = cache_item; - bound_presolve.probing_cache.probing_cache.insert({var_idx, entries_per_var}); + bound_presolve.probing_cache.probing_cache.insert({var_original, entries_per_var}); } else { - bound_presolve.probing_cache.probing_cache[var_idx][1] = cache_item; + bound_presolve.probing_cache.probing_cache[var_original][1] = cache_item; } } } @@ -497,6 +505,7 @@ void compute_cache_for_var(i_t var_idx, h_improved_lower_bounds, h_improved_upper_bounds, h_integer_indices, + problem.original_ids, n_of_implied_singletons); } } @@ -704,36 +713,69 @@ void apply_substitution_queue_to_problem( std::vector offset_values; std::vector coefficient_values; - // Get variable_mapping to convert current indices to original indices + // Get variable_mapping to convert current indices to post-Papilo frame auto h_variable_mapping = host_copy(problem.presolve_data.variable_mapping, problem.handle_ptr->get_stream()); problem.handle_ptr->sync_stream(); + // Staged rather than appended directly: all_substitutions is a hash map, so its iteration order + // is not reproducible, and the log's order has to be. merge_substitutions has already flattened + // chains and dropped bidirectional edges, so no record here depends on another and sorting by + // substituted_var only has to be a fixed order, not a particular one. + std::vector> staged_reconstructions; + staged_reconstructions.reserve(all_substitutions.size()); for (const auto& [substituting_var, substitutions] : all_substitutions) { for (const auto& [substituted_var, substitution] : substitutions) { CUOPT_LOG_TRACE("Applying substitution: %d -> %d", substitution.substituting_var, substitution.substituted_var); + cuopt_assert(substitution.substituted_var >= 0 && + substitution.substituted_var < (i_t)h_variable_mapping.size(), + "substituted_var out of variable_mapping range"); + cuopt_assert(substitution.substituting_var >= 0 && + substitution.substituting_var < (i_t)h_variable_mapping.size(), + "substituting_var out of variable_mapping range"); var_indices.push_back(substitution.substituted_var); substituting_var_indices.push_back(substitution.substituting_var); offset_values.push_back(substitution.offset); coefficient_values.push_back(substitution.coefficient); - // Store substitution for post-processing (convert to original variable IDs) - substitution_t sub; - sub.timestamp = substitution.timestamp; - sub.substituted_var = h_variable_mapping[substitution.substituted_var]; - sub.substituting_var = h_variable_mapping[substitution.substituting_var]; - sub.offset = substitution.offset; - sub.coefficient = substitution.coefficient; - problem.presolve_data.variable_substitutions.push_back(sub); - CUOPT_LOG_TRACE("Stored substitution for post-processing: x[%d] = %f + %f * x[%d]", - sub.substituted_var, - sub.offset, - sub.coefficient, - sub.substituting_var); + postsolve_reconstruction_t reconstruction; + reconstruction.kind = reconstruction_kind_t::AffineSub; + reconstruction.sub = substitution; + reconstruction.sub.substituted_var = h_variable_mapping[substitution.substituted_var]; + reconstruction.sub.substituting_var = h_variable_mapping[substitution.substituting_var]; + staged_reconstructions.push_back(std::move(reconstruction)); + CUOPT_LOG_TRACE("Stored AffineSub for post-processing: x[%d] = %f + %f * x[%d]", + staged_reconstructions.back().sub.substituted_var, + staged_reconstructions.back().sub.offset, + staged_reconstructions.back().sub.coefficient, + staged_reconstructions.back().sub.substituting_var); } } + // check are_exclusive to avoid emitting a nonsensical substitution with the variable on both + // sides + std::unordered_set substituting_vars(substituting_var_indices.begin(), + substituting_var_indices.end()); + for (i_t substituted_var : var_indices) { + if (substituting_vars.count(substituted_var) == 0) { continue; } + CUOPT_LOG_WARN( + "Skipping %zu probing substitutions: variable %d is both substituted and substituting", + var_indices.size(), + substituted_var); + return; + } + + std::sort(staged_reconstructions.begin(), + staged_reconstructions.end(), + [](const postsolve_reconstruction_t& a, + const postsolve_reconstruction_t& b) { + return a.sub.substituted_var < b.sub.substituted_var; + }); + auto& reconstructions = problem.presolve_data.postsolve_reconstructions; + reconstructions.insert(reconstructions.end(), + std::make_move_iterator(staged_reconstructions.begin()), + std::make_move_iterator(staged_reconstructions.end())); if (!var_indices.empty()) { problem.substitute_variables( @@ -853,6 +895,11 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, size_t step_size_hint) { raft::common::nvtx::range fun_scope("compute_probing_cache"); + + cuopt_assert(bound_presolve.probing_cache.probing_cache.empty(), + "probing cache is built once per solve"); + cuopt_assert(problem.original_ids.size() == (size_t)problem.n_variables, + "probing cache needs id maps that match the current column set"); // we dont want to compute the probing cache for all variables for time and computation resources auto priority_indices = compute_priority_indices_by_implied_integers(problem); CUOPT_LOG_DEBUG("Computing probing cache"); @@ -997,6 +1044,56 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, return problem_is_infeasible.load(); } +// incorporate implications discovered by block-BVE +template +void probing_cache_t::merge_forcings(const std::vector>& forcings, + std::vector>& fixings) +{ + i_t n_added = 0; + i_t n_tightened = 0; + i_t n_contradicted = 0; + for (const auto& forcing : forcings) { + cuopt_assert(forcing.var != forcing.forced_var, "self-forcing is not a projection finding"); + auto entry_it = probing_cache.find(forcing.var); + if (entry_it == probing_cache.end()) { continue; } + const f_t probed_val = forcing.value ? f_t(1) : f_t(0); + const f_t forced_val = forcing.forced_value ? f_t(1) : f_t(0); + for (cache_entry_t& entry : entry_it->second) { + if (entry.var_to_cached_bound_map.empty()) { continue; } + if (entry.val_interval.interval_type != interval_type_t::EQUALS) { continue; } + cuopt_assert(entry.val_interval.val == f_t(0) || entry.val_interval.val == f_t(1), ""); + if (entry.val_interval.val != probed_val) { continue; } + auto [bound_it, inserted] = entry.var_to_cached_bound_map.insert( + {forcing.forced_var, cached_bound_t{forced_val, forced_val}}); + if (inserted) { + ++n_added; + continue; + } + cached_bound_t& bound = bound_it->second; + cuopt_assert(bound.lb >= f_t(0) && bound.ub <= f_t(1), ""); + const f_t lb = std::max(bound.lb, forced_val); + const f_t ub = std::min(bound.ub, forced_val); + // Both the cached bound and the projection are valid and share the antecedent var == probed + // value, so an empty intersection proves only that the antecedent cannot hold. The slot is + // dead from here on, hence no tightening; the opposite value is the sound conclusion. + if (lb > ub) { + fixings.emplace_back(forcing.var, !forcing.value); + ++n_contradicted; + continue; + } + n_tightened += (lb != bound.lb || ub != bound.ub); + bound.lb = lb; + bound.ub = ub; + } + } + CUOPT_LOG_DEBUG( + "BVE forcings %zu: added %d and tightened %d probing cache bounds, %d contradicted a probe", + forcings.size(), + n_added, + n_tightened, + n_contradicted); +} + #define INSTANTIATE(F_TYPE) \ template bool compute_probing_cache(bound_presolve_t & bound_presolve, \ problem_t & problem, \ diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cuh b/cpp/src/mip_heuristics/presolve/probing_cache.cuh index 24d9a9cfc1..94d8af98ca 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cuh +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cuh @@ -44,7 +44,7 @@ struct val_interval_t { f_t first_probe, f_t second_probe, i_t& hit_interval_for_first_probe, - i_t& hit_interval_for_second_probe) + i_t& hit_interval_for_second_probe) const { if (interval_type == interval_type_t::EQUALS) { if (val == first_probe) { hit_interval_for_first_probe = interval; } @@ -73,6 +73,21 @@ struct cache_entry_t { std::unordered_map> var_to_cached_bound_map; }; +// A forcing read off an exactly projected block: var == value implies forced_var == forced_value. +template +struct probe_forcing_t { + i_t var; + i_t forced_var; + bool value; + bool forced_value; +}; + +template +struct probe_findings_t { + std::vector> forcings; + std::vector> fixings; // var forced to value by its block alone +}; + template class probing_cache_t { public: @@ -94,6 +109,8 @@ class probing_cache_t { f_t first_probe, f_t second_probe, f_t integrality_tolerance); + void merge_forcings(const std::vector>& forcings, + std::vector>& fixings); // add the results of probing cache to secondary CG structure if not already in a gub constraint. // use the same activity computation that we will use in BP rounding. // use GUB constraints to find fixings in bulk rounding diff --git a/cpp/src/mip_heuristics/presolve/semi_continuous.cu b/cpp/src/mip_heuristics/presolve/semi_continuous.cu index 33b7efff0e..51d2552746 100644 --- a/cpp/src/mip_heuristics/presolve/semi_continuous.cu +++ b/cpp/src/mip_heuristics/presolve/semi_continuous.cu @@ -115,6 +115,8 @@ bool reformulate_semi_continuous(optimization_problem_t& op_problem, std::vector* used_fallback_big_m, std::vector* semi_continuous_binary_to_original_indices) { + if (!op_problem.has_semi_continuous_variables()) { return false; } + // 1. Identify semi-continuous variables auto var_types = op_problem.get_variable_types_host(); auto var_lb = op_problem.get_variable_lower_bounds_host(); diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp index fdb5a0c88b..10784f7021 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp @@ -40,6 +40,7 @@ #include #include #include +#include #include #include #include @@ -677,6 +678,7 @@ void set_presolve_methods( if (category == problem_category_t::MIP) { // cuOpt custom GF2 presolver maybe_add(uptr(new cuopt::mathematical_optimization::mip::GF2Presolve())); + maybe_add(uptr(new cuopt::mathematical_optimization::mip::BHWCoeffReduce())); } // fast presolvers maybe_add(uptr(new papilo::SingletonCols())); @@ -724,7 +726,7 @@ void set_presolve_options(papilo::Presolve& presolver, { presolver.getPresolveOptions().tlim = time_limit; presolver.getPresolveOptions().threads = num_cpu_threads; // user setting or 0 (automatic) - presolver.getPresolveOptions().feastol = absolute_tolerance; + presolver.getPresolveOptions().feastol = 1e-5; if (max_rounds > 0) { presolver.getPresolveOptions().maxrounds = max_rounds; } if (dual_postsolve) { presolver.getPresolveOptions().componentsmaxint = -1; @@ -1293,9 +1295,9 @@ void third_party_presolve_t::undo(std::vector& primal_solution, template void third_party_presolve_t::uncrush_primal_solution( - const std::vector& reduced_primal, std::vector& full_primal) const + const std::vector& reduced_primal, std::vector& full_primal, bool check_postsolve) const { - if (presolver_ == cuopt::mathematical_optimization::presolver_t::PSLP) { + if (presolver_ == PSLP) { cuopt_expects(false, error_type_t::RuntimeError, "This code path should be never called, as this is meant for callbacks and they " @@ -1311,7 +1313,7 @@ void third_party_presolve_t::uncrush_primal_solution( bool is_optimal = false; auto status = post_solver.undo(reduced_sol, full_sol, *papilo_post_solve_storage_, is_optimal); - check_postsolve_status(status); + if (check_postsolve) check_postsolve_status(status); full_primal = std::move(full_sol.primal); } diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp index 60f4f7fc6d..79eb28d01e 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp @@ -141,7 +141,8 @@ class third_party_presolve_t { bool dual_postsolve); void uncrush_primal_solution(const std::vector& reduced_primal, - std::vector& full_primal) const; + std::vector& full_primal, + bool check_postsolve = true) const; void crush_primal_solution(const optimization_problem_t& reduced_problem, const std::vector& original_primal, diff --git a/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh b/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh index eff3b64910..5b4994d325 100644 --- a/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh +++ b/cpp/src/mip_heuristics/presolve/trivial_presolve.cuh @@ -272,8 +272,10 @@ void update_from_csr(problem_t& pb, bool remap_cache_ids) cuopt_func_call(test_renumbered_coo(make_span(cnst, 0, nnz_edge_count), pb)); - auto updated_n_cnst = 1 + cnst_renum_ids.back_element(handle_ptr->get_stream()); - auto updated_n_vars = 1 + var_renum_ids.back_element(handle_ptr->get_stream()); + const i_t updated_n_cnst = + cnst_renum_ids.is_empty() ? 0 : 1 + cnst_renum_ids.back_element(handle_ptr->get_stream()); + const i_t updated_n_vars = + var_renum_ids.is_empty() ? 0 : 1 + var_renum_ids.back_element(handle_ptr->get_stream()); pb.n_constraints = updated_n_cnst; pb.n_variables = updated_n_vars; diff --git a/cpp/src/mip_heuristics/problem/presolve_data.cu b/cpp/src/mip_heuristics/problem/presolve_data.cu index e834ce8c21..ae78c3778c 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cu +++ b/cpp/src/mip_heuristics/problem/presolve_data.cu @@ -101,6 +101,47 @@ bool presolve_data_t::pre_process_assignment(problem_t& prob return true; } +template +static uint32_t boundary_pattern(const bve_reconstruction_t& bve, + const std::vector& assignment) +{ + cuopt_assert(bve.witness.size() == (size_t{1} << bve.boundary.size()), + "block witness size mismatch"); + uint32_t pattern = 0; + for (size_t j = 0; j < bve.boundary.size(); ++j) { + cuopt_assert(bve.boundary[j] < (i_t)assignment.size(), "block boundary out of bounds"); + const int bit = (assignment[bve.boundary[j]] > 0.5) ? 1 : 0; + pattern |= (uint32_t)bit << j; + } + return pattern; +} + +template +static void reconstruct_bve_block(const bve_reconstruction_t& bve, + std::vector& assignment) +{ + const uint32_t witness = bve.witness[boundary_pattern(bve, assignment)]; + for (size_t k = 0; k < bve.interior.size(); ++k) { + cuopt_assert(bve.interior[k] < (i_t)assignment.size(), "block interior out of bounds"); + assignment[bve.interior[k]] = (witness >> k) & 1u; + } +} + +template +static void reconstruct_affine_sub(const substitution_t& sub, + std::vector& assignment) +{ + cuopt_assert(sub.substituted_var < (i_t)assignment.size(), "substituted_var out of bounds"); + cuopt_assert(sub.substituting_var < (i_t)assignment.size(), "substituting_var out of bounds"); + assignment[sub.substituted_var] = sub.offset + sub.coefficient * assignment[sub.substituting_var]; + CUOPT_LOG_DEBUG("Post-process substitution: x[%d] = %f + %f * x[%d] = %f", + sub.substituted_var, + sub.offset, + sub.coefficient, + sub.substituting_var, + assignment[sub.substituted_var]); +} + // this function is used to post process the assignment // it removes the additional variable for free variables // and expands the assignment to the original variable dimension @@ -135,19 +176,12 @@ void presolve_data_t::post_process_assignment( } } - // Apply variable substitutions from probing: x_substituted = offset + coefficient * - // x_substituting - for (const auto& sub : variable_substitutions) { - cuopt_assert(sub.substituted_var < (i_t)h_assignment.size(), "substituted_var out of bounds"); - cuopt_assert(sub.substituting_var < (i_t)h_assignment.size(), "substituting_var out of bounds"); - h_assignment[sub.substituted_var] = - sub.offset + sub.coefficient * h_assignment[sub.substituting_var]; - CUOPT_LOG_DEBUG("Post-process substitution: x[%d] = %f + %f * x[%d] = %f", - sub.substituted_var, - sub.offset, - sub.coefficient, - sub.substituting_var, - h_assignment[sub.substituted_var]); + // Reverse-append undo of the unified GPU-presolve reconstruction log + for (auto it = postsolve_reconstructions.rbegin(); it != postsolve_reconstructions.rend(); ++it) { + switch (it->kind) { + case reconstruction_kind_t::BlockBve: reconstruct_bve_block(it->bve, h_assignment); break; + case reconstruction_kind_t::AffineSub: reconstruct_affine_sub(it->sub, h_assignment); break; + } } // this separate resizing is needed because of the callback @@ -223,8 +257,8 @@ void presolve_data_t::set_papilo_presolve_data( } template -void presolve_data_t::papilo_uncrush_assignment( - problem_t& problem, rmm::device_uvector& assignment) const +void presolve_data_t::papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const { if (papilo_presolve_ptr == nullptr) { CUOPT_LOG_INFO("Papilo presolve data not set, skipping uncrushing assignment"); @@ -232,15 +266,12 @@ void presolve_data_t::papilo_uncrush_assignment( } cuopt_assert(assignment.size() == papilo_reduced_to_original_map.size(), "Papilo uncrush assignment size mismatch"); - auto h_assignment = cuopt::host_copy(assignment, problem.handle_ptr->get_stream()); + auto h_assignment = cuopt::host_copy(assignment, stream); std::vector full_assignment; papilo_presolve_ptr->uncrush_primal_solution(h_assignment, full_assignment); - assignment.resize(full_assignment.size(), problem.handle_ptr->get_stream()); - raft::copy(assignment.data(), - full_assignment.data(), - full_assignment.size(), - problem.handle_ptr->get_stream()); - problem.handle_ptr->sync_stream(); + assignment.resize(full_assignment.size(), stream); + raft::copy(assignment.data(), full_assignment.data(), full_assignment.size(), stream); + stream.synchronize(); } #if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/problem/presolve_data.cuh b/cpp/src/mip_heuristics/problem/presolve_data.cuh index 5f0b7f53c3..65b492a7e6 100644 --- a/cpp/src/mip_heuristics/problem/presolve_data.cuh +++ b/cpp/src/mip_heuristics/problem/presolve_data.cuh @@ -34,6 +34,22 @@ struct substitution_t { f_t coefficient; }; +template +struct bve_reconstruction_t { + std::vector interior; + std::vector boundary; + std::vector witness; // size 2^boundary.size() +}; + +enum class reconstruction_kind_t : uint8_t { AffineSub = 0, BlockBve = 1 }; + +template +struct postsolve_reconstruction_t { + reconstruction_kind_t kind{}; + substitution_t sub{}; + bve_reconstruction_t bve{}; +}; + template class presolve_data_t { public: @@ -62,7 +78,7 @@ class presolve_data_t { papilo_reduced_to_original_map(other.papilo_reduced_to_original_map), papilo_original_to_reduced_map(other.papilo_original_to_reduced_map), papilo_original_num_variables(other.papilo_original_num_variables), - variable_substitutions(other.variable_substitutions) + postsolve_reconstructions(other.postsolve_reconstructions) { } @@ -76,7 +92,7 @@ class presolve_data_t { fixed_var_assignment.begin(), fixed_var_assignment.end(), 0.); - variable_substitutions.clear(); + postsolve_reconstructions.clear(); } void reset_additional_vars(const problem_t& problem, const raft::handle_t* handle_ptr) @@ -106,8 +122,8 @@ class presolve_data_t { i_t original_num_variables); bool has_papilo_presolve_data() const { return papilo_presolve_ptr != nullptr; } i_t get_papilo_original_num_variables() const { return papilo_original_num_variables; } - void papilo_uncrush_assignment(problem_t& problem, - rmm::device_uvector& assignment) const; + void papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const; presolve_data_t(presolve_data_t&&) = default; presolve_data_t& operator=(presolve_data_t&&) = default; @@ -128,9 +144,9 @@ class presolve_data_t { std::vector papilo_reduced_to_original_map{}; std::vector papilo_original_to_reduced_map{}; i_t papilo_original_num_variables{0}; - // Variable substitutions from probing: x_substituted = offset + coefficient * x_substituting - // Applied in post_process_assignment to recover substituted variable values - std::vector> variable_substitutions; + // Append-only GPU-presolve reconstruction log (AffineSub from probing, BlockBve from block-BVE). + // post_process_assignment replays in reverse append order. + std::vector> postsolve_reconstructions; }; } // namespace mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/problem/problem.cu b/cpp/src/mip_heuristics/problem/problem.cu index ccba2d5f2b..b84206e08f 100644 --- a/cpp/src/mip_heuristics/problem/problem.cu +++ b/cpp/src/mip_heuristics/problem/problem.cu @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -204,6 +205,7 @@ problem_t::problem_t(const problem_t& problem_) var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + objective_offset(problem_.presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -263,6 +265,7 @@ problem_t::problem_t(const problem_t& problem_, var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + objective_offset(problem_.presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -365,6 +368,9 @@ problem_t::problem_t(const problem_t& problem_, bool no_deep var_names(problem_.var_names), row_names(problem_.row_names), objective_name(problem_.objective_name), + // presolve_data is declared ahead of this member and holds the live offset once presolve has + // moved it; problem_.objective_offset can be stale here. + objective_offset(presolve_data.objective_offset), is_scaled_(problem_.is_scaled_), preprocess_called(problem_.preprocess_called), objective_is_integral(problem_.objective_is_integral), @@ -1229,124 +1235,6 @@ void problem_t::insert_constraints(constraints_delta_t& h_co pdlp::combine_constraint_bounds(*this, combined_bounds); } -// Best rational approximation p/q to x with q <= max_denom, via continued fractions. -// Returns the last valid convergent if the denominator limit is reached. -std::pair rational_approximation(double x, int64_t max_denom, double epsilon) -{ - double ax = std::abs(x); - if (ax < epsilon) { return {0, 1}; } - - if (x < 0) { - auto [p, q] = rational_approximation(-x, max_denom, epsilon); - return {-p, q}; - } - - int64_t p_prev2 = 1, q_prev2 = 0; - int64_t p_prev1 = (int64_t)std::floor(x), q_prev1 = 1; - - double remainder = x - std::floor(x); - - for (int iter = 0; iter < 100; ++iter) { - if (std::abs(remainder) < 1e-15) break; - - remainder = 1.0 / remainder; - int64_t a = (int64_t)std::floor(remainder); - remainder -= a; - - int64_t p_curr = a * p_prev1 + p_prev2; - int64_t q_curr = a * q_prev1 + q_prev2; - - if (q_curr > max_denom) break; - // overflow guard - if (std::abs(p_curr) < std::abs(p_prev1)) break; - - p_prev2 = p_prev1; - q_prev2 = q_prev1; - p_prev1 = p_curr; - q_prev1 = q_curr; - - double approx_err = x - (double)p_curr / (double)q_curr; - if (std::abs(approx_err) < epsilon) break; - } - - return {p_prev1, q_prev1}; -} - -// Brute-force: try scalars 1..max_brute and return the smallest that makes all coefficients -// integral. -double find_scaling_brute_force(const std::vector& coefficients, - int max_brute = 100, - double tol = 1e-6) -{ - for (int s = 1; s <= max_brute; ++s) { - bool ok = true; - for (double c : coefficients) { - double scaled = s * c; - if (std::abs(scaled - std::round(scaled)) > tol) { - ok = false; - break; - } - } - if (ok) return (double)s; - } - return std::numeric_limits::quiet_NaN(); -} - -// Continued-fractions approach: rationalize each coefficient, compute scm/gcd incrementally. -double find_scaling_rational(const std::vector& coefficients, - double maxscale = 1e6, - int64_t maxdnom = 10000000, - double maxfinal = 10000, - double intcheck_tol = 1e-6) -{ - constexpr double no_scaling = std::numeric_limits::quiet_NaN(); - double epsilon = 1.0 / maxscale; - - int64_t gcd = 0; - int64_t scm = 1; - - for (double c : coefficients) { - auto [num, den] = rational_approximation(c, maxdnom, epsilon); - if (den == 0 || num == 0) continue; - - int64_t abs_num = std::abs(num); - if (gcd == 0) { - gcd = abs_num; - scm = den; - } else { - gcd = std::gcd(gcd, abs_num); - int64_t factor = den / std::gcd(scm, den); - int64_t new_scm; - if (__builtin_mul_overflow(scm, factor, &new_scm)) return no_scaling; - scm = new_scm; - } - - if ((double)scm / (double)gcd > maxscale) return no_scaling; - } - - if (gcd == 0) return 1.0; - - double intscalar = (double)scm / (double)gcd; - if (intscalar > maxfinal) return no_scaling; - - for (double c : coefficients) { - double scaled = intscalar * c; - if (std::abs(scaled - std::round(scaled)) > intcheck_tol) return no_scaling; - } - - return intscalar; -} - -// Finds the smallest integer scaling factor s such that s * c_i is integral for all i. -// Tries a brute-force sweep first (cheap, numerically robust), then falls back to -// continued fractions for larger scalars. -double find_objective_scaling_factor(const std::vector& coefficients) -{ - double s = find_scaling_brute_force(coefficients); - if (!std::isnan(s)) return s; - return find_scaling_rational(coefficients); -} - template void problem_t::set_implied_integers(const std::vector& implied_integer_indices) { @@ -2168,36 +2056,29 @@ void problem_t::set_constraints_from_host_user_problem( raft::common::nvtx::range fun_scope("set_constraints_from_host_user_problem"); cuopt_assert(user_problem.handle_ptr == handle_ptr, "handle mismatch"); cuopt_assert(user_problem.num_cols == n_variables, "num cols mismatch"); - n_constraints = user_problem.num_rows; - cuopt_assert(user_problem.rhs.size() == static_cast(n_constraints), "rhs size mismatch"); - cuopt_assert(user_problem.row_sense.size() == static_cast(n_constraints), + const i_t num_rows = user_problem.num_rows; + cuopt_assert(user_problem.rhs.size() == static_cast(num_rows), "rhs size mismatch"); + cuopt_assert(user_problem.row_sense.size() == static_cast(num_rows), "row sense size mismatch"); cuopt_assert(user_problem.range_rows.size() == user_problem.range_value.size(), "range rows/value size mismatch"); - csr_matrix_t csr_A(n_constraints, n_variables, user_problem.A.nnz()); + csr_matrix_t csr_A(num_rows, n_variables, user_problem.A.nnz()); user_problem.A.to_compressed_row(csr_A); - nnz = csr_A.row_start[n_constraints]; - empty = (nnz == 0 && n_constraints == 0 && n_variables == 0); - auto stream = handle_ptr->get_stream(); - cuopt::device_copy(coefficients, csr_A.x, stream); - cuopt::device_copy(variables, csr_A.j, stream); - cuopt::device_copy(offsets, csr_A.row_start, stream); - - std::vector h_constraint_lower_bounds(n_constraints); - std::vector h_constraint_upper_bounds(n_constraints); - std::vector range_value_per_row(n_constraints, f_t{0}); - std::vector is_range_row(n_constraints, 0); + std::vector h_constraint_lower_bounds(num_rows); + std::vector h_constraint_upper_bounds(num_rows); + std::vector range_value_per_row(num_rows, f_t{0}); + std::vector is_range_row(num_rows, 0); for (size_t idx = 0; idx < user_problem.range_rows.size(); ++idx) { auto row = user_problem.range_rows[idx]; - cuopt_assert(row >= 0 && row < n_constraints, "range row out of bounds"); + cuopt_assert(row >= 0 && row < num_rows, "range row out of bounds"); is_range_row[row] = 1; range_value_per_row[row] = user_problem.range_value[idx]; } const auto inf = std::numeric_limits::infinity(); - for (i_t i = 0; i < n_constraints; ++i) { + for (i_t i = 0; i < num_rows; ++i) { const f_t rhs = user_problem.rhs[i]; const char sense = user_problem.row_sense[i]; if (sense == 'E') { @@ -2214,32 +2095,66 @@ void problem_t::set_constraints_from_host_user_problem( cuopt_assert(false, "Unsupported row sense"); } } + set_constraints_from_host_csr(csr_A.row_start, + csr_A.j, + csr_A.x, + h_constraint_lower_bounds, + h_constraint_upper_bounds, + user_problem.row_names); +} - cuopt::device_copy(constraint_lower_bounds, h_constraint_lower_bounds, stream); - cuopt::device_copy(constraint_upper_bounds, h_constraint_upper_bounds, stream); - - if (!user_problem.row_names.empty()) { - row_names = user_problem.row_names; - } else if (row_names.size() != static_cast(n_constraints)) { - row_names.clear(); +template +void problem_t::set_constraints_from_host_csr(const std::vector& offsets_in, + const std::vector& variables_in, + const std::vector& coefficients_in, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& names) +{ + raft::common::nvtx::range fun_scope("set_constraints_from_host_csr"); + n_constraints = static_cast(row_lower.size()); + cuopt_assert(row_upper.size() == static_cast(n_constraints), "row bound size mismatch"); + cuopt_assert(offsets_in.size() == static_cast(n_constraints) + 1, + "offsets size mismatch"); + cuopt_assert(!offsets_in.empty() && offsets_in.front() == 0, "invalid CSR offsets"); + cuopt_assert(std::is_sorted(offsets_in.begin(), offsets_in.end()), "unsorted CSR offsets"); + cuopt_assert(variables_in.size() == coefficients_in.size(), "csr index/value size mismatch"); + cuopt_assert(static_cast(offsets_in.back()) == variables_in.size(), + "CSR offsets/entries size mismatch"); + cuopt_assert(names.empty() || names.size() == static_cast(n_constraints), + "row names size mismatch"); + for (i_t variable : variables_in) { + cuopt_assert(variable >= 0 && variable < n_variables, "CSR variable out of bounds"); } + nnz = static_cast(variables_in.size()); + empty = (nnz == 0 && n_constraints == 0 && n_variables == 0); + auto stream = handle_ptr->get_stream(); + cuopt::device_copy(coefficients, coefficients_in, stream); + cuopt::device_copy(variables, variables_in, stream); + cuopt::device_copy(offsets, offsets_in, stream); + cuopt::device_copy(constraint_lower_bounds, row_lower, stream); + cuopt::device_copy(constraint_upper_bounds, row_upper, stream); + + // the previous row set is gone: drop stale row names and any fixed-problem cache + row_names = names; integer_fixed_problem = nullptr; + fixing_helpers.reduction_in_rhs.resize(n_constraints, stream); - auto prev_dual_size = lp_state.prev_dual.size(); + thrust::fill(handle_ptr->get_thrust_policy(), + fixing_helpers.reduction_in_rhs.begin(), + fixing_helpers.reduction_in_rhs.end(), + f_t{0}); lp_state.prev_dual.resize(n_constraints, stream); - if (n_constraints > (i_t)prev_dual_size) { - thrust::fill(handle_ptr->get_thrust_policy(), - lp_state.prev_dual.begin() + prev_dual_size, - lp_state.prev_dual.end(), - f_t{0}); - } + thrust::fill( + handle_ptr->get_thrust_policy(), lp_state.prev_dual.begin(), lp_state.prev_dual.end(), f_t{0}); handle_ptr->sync_stream(); RAFT_CHECK_CUDA(stream); compute_transpose_of_problem(); combined_bounds.resize(n_constraints, stream); pdlp::combine_constraint_bounds(*this, combined_bounds); + recompute_auxilliary_data(false); } template @@ -2277,9 +2192,10 @@ void problem_t::set_papilo_presolve_data( } template -void problem_t::papilo_uncrush_assignment(rmm::device_uvector& assignment) const +void problem_t::papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const { - presolve_data.papilo_uncrush_assignment(const_cast(*this), assignment); + presolve_data.papilo_uncrush_assignment(assignment, stream); } template diff --git a/cpp/src/mip_heuristics/problem/problem.cuh b/cpp/src/mip_heuristics/problem/problem.cuh index bcc3f06fc2..fd38117b50 100644 --- a/cpp/src/mip_heuristics/problem/problem.cuh +++ b/cpp/src/mip_heuristics/problem/problem.cuh @@ -119,7 +119,12 @@ class problem_t { { return presolve_data.get_papilo_original_num_variables(); } - void papilo_uncrush_assignment(rmm::device_uvector& assignment) const; + void papilo_uncrush_assignment(rmm::device_uvector& assignment, + rmm::cuda_stream_view stream) const; + void papilo_uncrush_assignment(rmm::device_uvector& assignment) const + { + papilo_uncrush_assignment(assignment, handle_ptr->get_stream()); + } void compute_transpose_of_problem(); f_t get_user_obj_from_solver_obj(f_t solver_obj) const; f_t get_solver_obj_from_user_obj(f_t user_obj) const; @@ -143,6 +148,14 @@ class problem_t { cuopt::mathematical_optimization::simplex::user_problem_t& user_problem) const; void set_constraints_from_host_user_problem( const cuopt::mathematical_optimization::simplex::user_problem_t& user_problem); + // Replace the constraint matrix + row bounds in place from host CSR + // Used by presolve passes that rewrite rows in place + void set_constraints_from_host_csr(const std::vector& offsets, + const std::vector& variables, + const std::vector& coefficients, + const std::vector& row_lower, + const std::vector& row_upper, + const std::vector& names); uint32_t get_fingerprint() const; @@ -326,7 +339,7 @@ class problem_t { std::vector row_names{}; /** name of the objective (only a single objective is currently allowed) */ std::string objective_name; - f_t objective_offset; + f_t objective_offset{0}; bool is_scaled_{false}; bool preprocess_called{false}; bool objective_is_integral{false}; diff --git a/cpp/src/mip_heuristics/root_heuristics.hpp b/cpp/src/mip_heuristics/root_heuristics.hpp new file mode 100644 index 0000000000..17a7a73e51 --- /dev/null +++ b/cpp/src/mip_heuristics/root_heuristics.hpp @@ -0,0 +1,190 @@ +/* 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 "feasibility_jump/fj_cpu_worker.cuh" + +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +template +struct cut_pass_heuristics_t { + std::vector var_types_; + csr_matrix_t Arow_; + std::vector root_solution_; + std::vector root_edge_norm_; + + std::unique_ptr> submip_worker_; + fj_cpu_worker_t fj_cpu_worker_; + + cut_pass_heuristics_t(const csr_matrix_t& Arow, + const std::vector& var_types, + const std::vector& root_solution, + const std::vector& root_edge_norm) + : var_types_(var_types), + Arow_(Arow), + root_solution_(root_solution), + root_edge_norm_(root_edge_norm), + submip_worker_(nullptr) {}; + + ~cut_pass_heuristics_t() { stop_and_sync(); } + + void send_stop_signal() + { + fj_cpu_worker_.send_stop_signal(); + if (submip_worker_) { submip_worker_->halt = true; } + } + + void stop_and_sync() + { + fj_cpu_worker_.stop(); + if (submip_worker_) { + diving_worker_t* worker = submip_worker_.get(); + worker->halt = true; +#pragma omp taskwait depend(in : *worker) + submip_worker_.reset(); + } + } + + diving_worker_t* create_submip_worker( + i_t id, + const simplex::lp_problem_t& lp, + const simplex::simplex_solver_settings_t& settings, + f_t root_obj, + const std::vector& root_vstatus, + const std::vector& sol, + search_strategy_t type) + { + submip_worker_ = std::make_unique>( + id, lp, Arow_, var_types_, settings, root_solution_, root_edge_norm_); + submip_worker_->start_node = mip_node_t(root_obj, root_vstatus); + submip_worker_->leaf_vstatus = root_vstatus; + submip_worker_->leaf_solution.x = sol; + submip_worker_->recompute_bounds = false; + submip_worker_->recompute_basis = true; + submip_worker_->search_strategy = type; + submip_worker_->set_active(); + + return submip_worker_.get(); + } +}; + +/// \brief Object Representing the heuristics run on the root node. +template +struct root_heuristics_t { + // List of the heuristics that run alongside a single cut pass. + // It holds the workers and all the necessary information. + // + // We use the `shared_ptr` here so the object is only destroyed when the task terminates + // (we declare the `shared_ptr` as firstprivate in the task, so they live until the end the + // task). In this way, we can send the stop signal, destroy the entry in the list and the + // object itself will be destroyed when all related tasks ends. + std::list>> cut_passes_heuristics_; + + // Count the number of active workers. Same reason as above. + std::shared_ptr> worker_count_; + i_t max_workers_; + + // CPU FJ lanes that outlive a single cut pass. + std::vector>> persistent_lanes_; + // Shared by every CPU FJ lane of the root phase, persistent and per-cut-pass alike. + std::shared_ptr> shared_incumbent_; + + root_heuristics_t(i_t max_workers) + : worker_count_(std::make_shared>(0)), + max_workers_(max_workers), + shared_incumbent_(make_fj_cpu_shared_incumbent()) + { + } + + ~root_heuristics_t() { stop_and_sync(); } + + // Must be called from the same task region as stop_and_sync: run_async's task dependence is + // matched only by a taskwait in the encountering region. + void start_persistent_lanes(const simplex::lp_problem_t& lp, + const std::vector& var_types, + i_t n_structural, + const std::vector& seed_assignment, + const simplex::simplex_solver_settings_t& settings, + i_t n_lanes, + f_t time_limit, + int64_t base_seed, + std::function&, double)> callback) + { + persistent_lanes_.reserve(n_lanes); + for (i_t k = 0; k < n_lanes; ++k) { + auto lane = std::make_unique>(); + lane->improvement_callback = callback; + lane->shared_incumbent = shared_incumbent_; + lane->create_worker(lp, + var_types, + n_structural, + seed_assignment, + settings, + "[Root FJ lane " + std::to_string(k) + "] ", + base_seed + k, + k); + lane->run_async(time_limit); + persistent_lanes_.push_back(std::move(lane)); + } + } + + void stop_and_sync() + { + for (auto& lane : persistent_lanes_) { + lane->send_stop_signal(); + } + for (auto& heuristic : cut_passes_heuristics_) { + heuristic->send_stop_signal(); + } + + for (auto& lane : persistent_lanes_) { + lane->stop(); + } + persistent_lanes_.clear(); + for (auto& heuristic : cut_passes_heuristics_) { + heuristic->stop_and_sync(); + } + } + + std::shared_ptr> create_new_cut_pass_heuristic( + i_t cut_pass, + const csr_matrix_t& Arow, + const std::vector& var_types, + const std::vector& root_solution, + const std::vector& root_edge_norm) + { + // If we already exhausted all threads for the root heuristics, stop workers for the + // oldest set of heuristics launched. Leave 2 threads for the cut passes and the clique + // table generation. Add the number of workers that will be launched (1 submip worker + + // 1 CPU FJ worker). + i_t clique_table_generation = cut_pass == 0 ? 1 : 0; + if (*worker_count_ + 3 + clique_table_generation > max_workers_ && + !cut_passes_heuristics_.empty()) { + cut_passes_heuristics_.begin()->get()->send_stop_signal(); + cut_passes_heuristics_.erase(cut_passes_heuristics_.begin()); + } + + auto& heuristic = cut_passes_heuristics_.emplace_back( + std::make_shared>( + Arow, var_types, root_solution, root_edge_norm)); + // Read by create_worker, so it has to be in place before the caller builds the climber. + heuristic->fj_cpu_worker_.shared_incumbent = shared_incumbent_; + return heuristic; + } +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solution/solution.cu b/cpp/src/mip_heuristics/solution/solution.cu index 3b00fca7a8..197db0627c 100644 --- a/cpp/src/mip_heuristics/solution/solution.cu +++ b/cpp/src/mip_heuristics/solution/solution.cu @@ -5,6 +5,7 @@ */ /* clang-format on */ +#include #include "feasibility_test.cuh" #include "solution.cuh" #include "solution_kernels.cuh" @@ -652,4 +653,29 @@ template class solution_t; template class solution_t; #endif +template +void build_start_assignment(problem_t& problem, + solution_t& solution, + const raft::handle_t* handle_ptr) +{ + // Default: zero, projected into the variable bounds. Deliberately the simplest + // thing that works -- the seeding strategy is what this hook exists to change. + thrust::fill(handle_ptr->get_thrust_policy(), + solution.assignment.begin(), + solution.assignment.end(), + f_t{0}); + clamp_within_var_bounds(solution.assignment, &problem, handle_ptr); + handle_ptr->sync_stream(); +} + +#if MIP_INSTANTIATE_FLOAT +template void build_start_assignment( + problem_t&, solution_t&, const raft::handle_t*); +#endif + +#if MIP_INSTANTIATE_DOUBLE +template void build_start_assignment( + problem_t&, solution_t&, const raft::handle_t*); +#endif + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solution/solution.cuh b/cpp/src/mip_heuristics/solution/solution.cuh index f243937d3e..4839ba1fb8 100644 --- a/cpp/src/mip_heuristics/solution/solution.cuh +++ b/cpp/src/mip_heuristics/solution/solution.cuh @@ -153,4 +153,13 @@ class solution_t { void test_variable_bounds(bool check_integer = true, i_t* is_feasible = nullptr); }; +// Builds the start assignment every climber is derived from. Defined in +// solution.cu, so editing it recompiles one translation unit rather than every +// file that includes this header. Runs inside the measured window: a better start +// has to be worth what it costs to build. +template +void build_start_assignment(problem_t& problem, + solution_t& solution, + const raft::handle_t* handle_ptr); + } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solution_publication.cuh b/cpp/src/mip_heuristics/solution_publication.cuh new file mode 100644 index 0000000000..0e5c1d92ea --- /dev/null +++ b/cpp/src/mip_heuristics/solution_publication.cuh @@ -0,0 +1,141 @@ +/* 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 +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// Single point at which MIP incumbents are reported to the user get-solution callbacks. +// The heuristic thread (through the population) and the branch-and-bound thread both publish +// here, so the guard on the last published objective is shared and every incumbent is reported +// once, at the moment it is found rather than when the heuristic thread next drains its queue. +template +class solution_publication_t { + public: + solution_publication_t(const mip_solver_settings_t& settings, + const solver_stats_t& stats) + : settings_(settings), stats_(stats) + { + if (has_get_solution_callback()) { + RAFT_CUDA_TRY(cudaGetDevice(&device_id_)); + handle_ = std::make_unique(); + } + } + + // Whether any get-solution callback is registered. Callers can use this to skip assembling + // the host assignment that publish_if_better would otherwise discard. + bool enabled() const { return handle_ != nullptr; } + + // `assignment` and `solver_objective` are in problem_ptr's solver space, which is always + // oriented as a minimization. Returns whether the incumbent was published. + // + // Post-processing runs on a private stream, so this is safe to call from the branch-and-bound + // thread while the heuristic thread owns problem_ptr->handle_ptr's stream. + bool publish_if_better(problem_t* problem_ptr, + const std::vector& assignment, + f_t solver_objective) + { + if (handle_ == nullptr) { return false; } + cuopt_assert(problem_ptr != nullptr, "Publication problem pointer must not be null"); + cuopt_assert(std::isfinite(solver_objective), "Published objective must be finite"); + + std::lock_guard lock(mutex_); + if (!(solver_objective < best_published_objective_)) { return false; } + best_published_objective_ = solver_objective; + + const auto user_assignment = build_user_assignment(problem_ptr, assignment); + const f_t user_objective = problem_ptr->get_user_obj_from_solver_obj(solver_objective); + const f_t user_bound = stats_.get_solution_bound(); + CUOPT_LOG_DEBUG("Publishing incumbent: objective %g, %lu variables", + user_objective, + user_assignment.size()); + + for (auto callback : settings_.get_mip_callbacks()) { + if (callback == nullptr || + callback->get_type() != internals::base_solution_callback_type::GET_SOLUTION) { + continue; + } + // Each callback gets its own copies: the interface hands out mutable pointers. + std::vector callback_assignment(user_assignment); + std::vector callback_objective(1, user_objective); + std::vector callback_bound(1, user_bound); + auto get_sol_callback = static_cast(callback); + get_sol_callback->get_solution(callback_assignment.data(), + callback_objective.data(), + callback_bound.data(), + get_sol_callback->get_user_data()); + } + return true; + } + + private: + // Lifts a solver-space assignment into the space the callbacks were set up for. + std::vector build_user_assignment(problem_t* problem_ptr, + const std::vector& assignment) + { + // The B&B thread may never have selected a device of its own. + RAFT_CUDA_TRY(cudaSetDevice(device_id_)); + auto stream = handle_->get_stream(); + rmm::device_uvector d_assignment(assignment.size(), stream); + raft::copy(d_assignment.data(), assignment.data(), assignment.size(), stream); + // post_process_assignment writes through problem_ptr->presolve_data.fixed_var_assignment, + // which both publishing threads share: the caller's lock is what keeps them apart. + problem_ptr->post_process_assignment(d_assignment, true, stream); + if (problem_ptr->has_papilo_presolve_data()) { + problem_ptr->papilo_uncrush_assignment(d_assignment, stream); + } + auto user_assignment = cuopt::host_copy(d_assignment, stream); + if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( + settings_)) { + strip_semi_continuous_auxiliaries_from_assignment( + user_assignment, + mip_solver_settings_accessor::get_semi_continuous_original_num_variables( + settings_)); + } + return user_assignment; + } + + bool has_get_solution_callback() const + { + for (auto callback : settings_.get_mip_callbacks()) { + if (callback != nullptr && + callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { + return true; + } + } + return false; + } + + const mip_solver_settings_t& settings_; + const solver_stats_t& stats_; + int device_id_{0}; + // Null when no get-solution callback is registered, which also disables publication. + std::unique_ptr handle_; + std::mutex mutex_; + f_t best_published_objective_{std::numeric_limits::max()}; +}; + +} // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index f55aca6878..c42bac4365 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -198,7 +199,7 @@ mip_solution_t run_mip_solver( scaled_problem.preprocess_problem(); scaled_problem.related_vars_time_limit = settings.heuristic_params.related_vars_time_limit; const i_t n_vars_before = scaled_problem.n_variables; - mip::trivial_presolve(scaled_problem); + mip::trivial_presolve(scaled_problem, /*remap_cache_ids=*/true); #ifdef DETECT_SYMMETRY_BEFORE_PRESOLVE // Trivial presolve may remove unused variables and renumber the remaining ones. @@ -254,7 +255,6 @@ mip_solution_t run_mip_solver( settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && problem.original_problem_ptr->get_n_integers() > 0; if (run_early_cpufj) { - auto early_fj_start = std::chrono::steady_clock::now(); auto* presolver_ptr = problem.presolve_data.papilo_presolve_ptr; auto mip_callbacks = settings.get_mip_callbacks(); f_t no_bound = problem.presolve_data.objective_scaling_factor >= 0 ? (f_t)-1e20 : (f_t)1e20; @@ -269,22 +269,19 @@ mip_solution_t run_mip_solver( mip_solver_settings_accessor::get_semi_continuous_original_num_variables( settings), ctx_ptr = &solver.context, - early_fj_start](f_t solver_obj, - f_t user_obj, - const std::vector& assignment, - const char* heuristic_name) { + &timer](f_t solver_obj, + f_t user_obj, + const std::vector& assignment, + const char* heuristic_name) { std::vector user_assignment; presolver_ptr->uncrush_primal_solution(assignment, user_assignment); ctx_ptr->initial_incumbent_assignment = user_assignment; ctx_ptr->initial_upper_bound = user_obj; - double elapsed = - std::chrono::duration(std::chrono::steady_clock::now() - early_fj_start) - .count(); CUOPT_LOG_INFO( - "New solution from early primal heuristics (%s). Objective %+.6e. Time %.2f", + "New solution from early primal heuristics (%s). Objective %+.6e. Time %.3f", heuristic_name, user_obj, - elapsed); + timer.elapsed_time()); invoke_solution_callbacks(mip_callbacks, has_semi_continuous_callback_translation, semi_continuous_original_num_variables, @@ -299,9 +296,11 @@ mip_solution_t run_mip_solver( if (std::isfinite(initial_upper_bound)) { early_cpufj->set_best_objective(problem.get_solver_obj_from_user_obj(initial_upper_bound)); } - early_cpufj->start(); + early_cpufj->start(omp_get_num_threads() - CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS); solver.context.early_cpufj_ptr = early_cpufj.get(); - CUOPT_LOG_DEBUG("Started early CPUFJ on papilo-presolved problem during cuOpt presolve"); + CUOPT_LOG_DEBUG( + "Started early CPUFJ on papilo-presolved problem during cuOpt presolve with %d lanes", + early_cpufj->lane_count()); } auto presolved_sol = solver.run_solver(); @@ -376,16 +375,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p raft::common::nvtx::range fun_scope("Running solver"); auto timer = timer_t(time_limit); - problem_checking_t::check_problem_representation(op_problem); - problem_checking_t::check_initial_solution_representation(op_problem, settings); - - CUOPT_LOG_INFO( - "Solving a problem with %d constraints, %d variables (%d integers), and %d nonzeros", - op_problem.get_n_constraints(), - op_problem.get_n_variables(), - op_problem.get_n_integers(), - op_problem.get_nnz()); - // Reformulate semi-continuous variables (x = 0 OR L <= x <= U) before Papilo presolve. // Uses deterministic CPU bounds strengthening to derive tight upper bounds for SC vars with // infinite UB. @@ -407,15 +396,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p settings, n_orig_before_sc, semi_continuous_binary_to_original_indices); } - op_problem.print_scaling_information(); - - // Check for crossing bounds. Return infeasible if there are any - if (problem_checking_t::has_crossing_bounds(op_problem)) { - return mip_solution_t(mip_termination_status_t::Infeasible, - solver_stats_t{}, - op_problem.get_handle_ptr()->get_stream()); - } - for (auto callback : settings.get_mip_callbacks()) { auto callback_num_variables = op_problem.get_n_variables(); if (mip_solver_settings_accessor::has_semi_continuous_callback_translation( @@ -444,16 +424,6 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p } #endif - if (settings.mip_scaling != CUOPT_MIP_SCALING_OFF) { - mip::mip_scaling_strategy_t scaling(op_problem); - scaling.scale_problem(settings.mip_scaling != CUOPT_MIP_SCALING_NO_OBJECTIVE); - } - double presolve_time = 0.0; - std::unique_ptr> presolver; - std::optional> presolve_result_opt; - mip::problem_t problem( - op_problem, settings.get_tolerances(), settings.determinism_mode == CUOPT_MODE_DETERMINISTIC); - auto run_presolve = settings.presolver != presolver_t::None; bool has_set_solution_callback = false; for (auto callback : settings.get_mip_callbacks()) { @@ -481,8 +451,8 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::vector early_incumbent_pool; // Track best incumbent found during presolve (shared across CPU and GPU FJ). - // early_best_objective is in the original problem's solver-space (always minimization), - // used for fast comparison in the callback. + // The CPU and GPU heuristics can use differently scaled solver spaces, so compare their + // objectives in a common minimization-oriented user space. // early_best_user_obj is the corresponding user-space objective, // passed to run_mip for correct cross-space conversion. // We attempt to crush early-heuristics solutions into the presolved space. @@ -491,7 +461,7 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p // but is dropped due to these dual reductions, and we lose a good solution. // This is why we still keep the solution around in original-space // and later extract it at the end of the solve. - std::atomic early_best_objective{std::numeric_limits::infinity()}; + std::atomic early_best_user_score{std::numeric_limits::infinity()}; f_t early_best_user_obj{std::numeric_limits::infinity()}; std::vector early_best_user_assignment; std::mutex early_callback_mutex; @@ -500,57 +470,93 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p std::unique_ptr> early_gpufj; bool run_early_fj = run_presolve && settings.determinism_mode != CUOPT_MODE_DETERMINISTIC && - op_problem.get_n_integers() > 0 && op_problem.get_n_constraints() > 0; - f_t no_bound = problem.presolve_data.objective_scaling_factor >= 0 ? (f_t)-1e20 : (f_t)1e20; - if (run_early_fj) { - auto early_fj_start = std::chrono::steady_clock::now(); - auto early_fj_callback = - [&early_best_objective, - &early_best_user_obj, - &early_best_user_assignment, - &early_incumbent_pool, - &early_callback_mutex, - early_fj_start, - mip_callbacks = settings.get_mip_callbacks(), - has_semi_continuous_callback_translation = - mip_solver_settings_accessor::has_semi_continuous_callback_translation( - settings), - semi_continuous_original_num_variables = - mip_solver_settings_accessor::get_semi_continuous_original_num_variables( - settings), - no_bound](f_t solver_obj, - f_t user_obj, - const std::vector& assignment, - const char* heuristic_name) { - std::lock_guard lock(early_callback_mutex); - if (solver_obj >= early_best_objective.load()) { return; } - early_best_objective.store(solver_obj); - early_best_user_obj = user_obj; - early_best_user_assignment = assignment; - early_incumbent_pool.push_back({user_obj, assignment}); - double elapsed = - std::chrono::duration(std::chrono::steady_clock::now() - early_fj_start) - .count(); - CUOPT_LOG_INFO( - "New solution from early primal heuristics (%s). Objective %+.6e. Time %.2f", - heuristic_name, - user_obj, - elapsed); - auto user_assignment = assignment; - invoke_solution_callbacks(mip_callbacks, - has_semi_continuous_callback_translation, - semi_continuous_original_num_variables, - user_obj, - user_assignment, - no_bound); - }; + op_problem.get_problem_category() != problem_category_t::LP && + op_problem.get_n_constraints() > 0; + const f_t objective_sense = op_problem.get_sense() ? f_t{-1} : f_t{1}; + f_t no_bound = objective_sense > f_t{0} ? (f_t)-1e20 : (f_t)1e20; + auto early_fj_callback = + [&early_best_user_score, + &early_best_user_obj, + &early_best_user_assignment, + &early_incumbent_pool, + &early_callback_mutex, + &timer, + objective_sense, + mip_callbacks = settings.get_mip_callbacks(), + has_semi_continuous_callback_translation = + mip_solver_settings_accessor::has_semi_continuous_callback_translation(settings), + semi_continuous_original_num_variables = + mip_solver_settings_accessor::get_semi_continuous_original_num_variables( + settings), + no_bound]( + f_t, f_t user_obj, const std::vector& assignment, const char* heuristic_name) { + std::lock_guard lock(early_callback_mutex); + const f_t objective = objective_sense * user_obj; + if (objective >= early_best_user_score.load()) { return; } + early_best_user_score.store(objective); + early_best_user_obj = user_obj; + early_best_user_assignment = assignment; + early_incumbent_pool.push_back({user_obj, assignment}); + CUOPT_LOG_INFO("New solution from early primal heuristics (%s). Objective %+.6e. Time %.3f", + heuristic_name, + user_obj, + timer.elapsed_time()); + auto user_assignment = assignment; + invoke_solution_callbacks(mip_callbacks, + has_semi_continuous_callback_translation, + semi_continuous_original_num_variables, + user_obj, + user_assignment, + no_bound); + }; + if (run_early_fj) { // Start early CPUFJ on original problem (will restart on presolved problem after Papilo) early_cpufj = std::make_unique>( op_problem, settings.get_tolerances(), early_fj_callback); - early_cpufj->start(); - CUOPT_LOG_DEBUG("Started early CPUFJ on original problem"); + // Papilo runs on its own threads, so the team is otherwise idle here. + early_cpufj->start(omp_get_num_threads() - CUOPT_MIP_EARLY_CPUFJ_RESERVED_THREADS); + CUOPT_LOG_DEBUG("Started early CPUFJ on original problem with %d lanes", + early_cpufj->lane_count()); + } + + auto early_cpufj_guard = cuopt::scope_guard([&]() { + if (early_cpufj) { + early_cpufj->stop(); + early_cpufj.reset(); + } + }); + + problem_checking_t::check_problem_representation(op_problem); + problem_checking_t::check_initial_solution_representation(op_problem, settings); + + CUOPT_LOG_INFO( + "Solving a problem with %d constraints, %d variables (%d integers), and %d nonzeros", + op_problem.get_n_constraints(), + op_problem.get_n_variables(), + op_problem.get_n_integers(), + op_problem.get_nnz()); + op_problem.print_scaling_information(); + + // Check for crossing bounds. Return infeasible if there are any + if (problem_checking_t::has_crossing_bounds(op_problem)) { + return mip_solution_t(mip_termination_status_t::Infeasible, + solver_stats_t{}, + op_problem.get_handle_ptr()->get_stream()); + } + + if (settings.mip_scaling != CUOPT_MIP_SCALING_OFF) { + mip::mip_scaling_strategy_t scaling(op_problem); + scaling.scale_problem(settings.mip_scaling != CUOPT_MIP_SCALING_NO_OBJECTIVE); + } + double presolve_time = 0.0; + std::unique_ptr> presolver; + std::optional> presolve_result_opt; + mip::problem_t problem( + op_problem, settings.get_tolerances(), settings.determinism_mode == CUOPT_MODE_DETERMINISTIC); + + if (run_early_fj) { // Start early GPU FJ (uses GPU while CPU is busy with Papilo) early_gpufj = std::make_unique>(op_problem, settings, early_fj_callback); diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index f8eac0c4d8..f0a5a3c5aa 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -69,6 +69,10 @@ struct branch_and_bound_solution_helper_t { void solution_callback(std::vector& solution, f_t objective) { + if (dm->context.settings.determinism_mode == CUOPT_MODE_OPPORTUNISTIC) { + dm->context.solution_publication.publish_if_better( + dm->context.problem_ptr, solution, objective); + } dm->population.add_external_solution(solution, objective, solution_origin_t::BRANCH_AND_BOUND); } @@ -197,12 +201,8 @@ solution_t mip_solver_t::run_solver() if (context.problem_ptr->empty) { CUOPT_LOG_INFO("Problem fully reduced in presolve"); sol.set_problem_fully_reduced(); - for (auto callback : context.settings.get_mip_callbacks()) { - if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { - auto get_sol_callback = static_cast(callback); - dm.population.invoke_get_solution_callback(sol, get_sol_callback); - } - } + context.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); context.problem_ptr->post_process_solution(sol); return sol; } @@ -237,12 +237,8 @@ solution_t mip_solver_t::run_solver() if (run_presolve && context.problem_ptr->empty) { CUOPT_LOG_INFO("Problem full reduced in presolve"); sol.set_problem_fully_reduced(); - for (auto callback : context.settings.get_mip_callbacks()) { - if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { - auto get_sol_callback = static_cast(callback); - dm.population.invoke_get_solution_callback(sol, get_sol_callback); - } - } + context.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); context.problem_ptr->post_process_solution(sol); return sol; } @@ -273,12 +269,8 @@ solution_t mip_solver_t::run_solver() sol.set_problem_fully_reduced(); } if (opt_sol.get_termination_status() == pdlp_termination_status_t::Optimal) { - for (auto callback : context.settings.get_mip_callbacks()) { - if (callback->get_type() == internals::base_solution_callback_type::GET_SOLUTION) { - auto get_sol_callback = static_cast(callback); - dm.population.invoke_get_solution_callback(sol, get_sol_callback); - } - } + context.solution_publication.publish_if_better( + context.problem_ptr, sol.get_host_assignment(), sol.get_objective()); } context.problem_ptr->post_process_solution(sol); return sol; @@ -445,10 +437,10 @@ solution_t mip_solver_t::run_solver() branch_and_bound->set_concurrent_lp_root_solve(true); context.problem_ptr->branch_and_bound_callback = - std::bind(&mip::branch_and_bound_t::set_solution_from_heuristics, - branch_and_bound.get(), - std::placeholders::_1, - std::placeholders::_2); + [bb = branch_and_bound.get()](const std::vector& solution, + heuristics_origin_t origin) { + return bb->set_solution_from_heuristics(solution, origin); + }; } else if (context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC) { branch_and_bound->set_concurrent_lp_root_solve(false); // TODO once deterministic GPU heuristics are integrated diff --git a/cpp/src/mip_heuristics/solver_context.cuh b/cpp/src/mip_heuristics/solver_context.cuh index f98386cbaf..344d4e8d86 100644 --- a/cpp/src/mip_heuristics/solver_context.cuh +++ b/cpp/src/mip_heuristics/solver_context.cuh @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -58,6 +59,8 @@ struct mip_solver_context_t { std::atomic preempt_heuristic_solver_ = false; const mip_solver_settings_t settings; solver_stats_t stats; + // Every incumbent reported to the user goes through here, from whichever thread found it. + solution_publication_t solution_publication{settings, stats}; // Work limit context for tracking work units in deterministic mode (shared across all timers in // GPU heuristic loop) work_limit_context_t gpu_heur_loop{"GPUHeur"}; diff --git a/cpp/src/pdlp/cpu_optimization_problem.cpp b/cpp/src/pdlp/cpu_optimization_problem.cpp index 4b970eb6ec..93e86b7da6 100644 --- a/cpp/src/pdlp/cpu_optimization_problem.cpp +++ b/cpp/src/pdlp/cpu_optimization_problem.cpp @@ -29,20 +29,36 @@ namespace cuopt::mathematical_optimization { namespace { -// Classify a problem as LP / MIP / IP from its (enum) variable types. Single source of truth -// shared by set_variable_types() and adopt_from_mps_data_model() so the detection rule lives in -// one place. Empty types (no variables declared) classify as LP, matching the populate path where -// set_variable_types() is skipped and the category keeps its LP default. -problem_category_t problem_category_from_variable_types(const std::vector& variable_types) -{ - if (variable_types.empty()) { return problem_category_t::LP; } - const std::size_t n_discrete = static_cast( - std::count_if(variable_types.begin(), variable_types.end(), [](var_t v) { - return v == var_t::INTEGER || v == var_t::SEMI_CONTINUOUS; - })); - if (n_discrete == variable_types.size()) { return problem_category_t::IP; } - if (n_discrete > 0) { return problem_category_t::MIP; } - return problem_category_t::LP; +// Classify a problem as LP / MIP / IP from its (enum) variable types, and whether any +// SEMI_CONTINUOUS vars are present. Single source of truth shared by set_variable_types() and +// adopt_from_mps_data_model() so the detection rule lives in one place. Empty types (no variables +// declared) classify as LP with no SC, matching the populate path where set_variable_types() is +// skipped and the category keeps its LP default. +struct variable_type_summary_t { + problem_category_t category; + bool has_semi_continuous; +}; + +variable_type_summary_t summarize_variable_types(const std::vector& variable_types) +{ + if (variable_types.empty()) { + return {problem_category_t::LP, false}; + } + size_t n_discrete = 0; + bool has_semi_continuous = false; + for (var_t v : variable_types) { + if (v == var_t::SEMI_CONTINUOUS) { + has_semi_continuous = true; + ++n_discrete; + } else if (v == var_t::INTEGER) { + ++n_discrete; + } + } + if (n_discrete == variable_types.size()) { + return {problem_category_t::IP, has_semi_continuous}; + } + if (n_discrete > 0) { return {problem_category_t::MIP, has_semi_continuous}; } + return {problem_category_t::LP, false}; } } // namespace @@ -232,7 +248,9 @@ void cpu_optimization_problem_t::set_variable_types(const var_t* varia variable_types_.resize(size); std::copy(variable_types, variable_types + size, variable_types_.begin()); - problem_category_ = problem_category_from_variable_types(variable_types_); + const auto summary = summarize_variable_types(variable_types_); + problem_category_ = summary.category; + has_semi_continuous_variables_ = summary.has_semi_continuous; } template @@ -513,6 +531,12 @@ problem_category_t cpu_optimization_problem_t::get_problem_category() return problem_category_; } +template +bool cpu_optimization_problem_t::has_semi_continuous_variables() const noexcept +{ + return has_semi_continuous_variables_; +} + template const std::vector& cpu_optimization_problem_t::get_variable_names() const { @@ -1171,7 +1195,9 @@ void cpu_optimization_problem_t::adopt_from_mps_data_model( for (size_t i = 0; i < model.var_types_.size(); ++i) { variable_types_[i] = char_to_var_type(model.var_types_[i]); } - problem_category_ = problem_category_from_variable_types(variable_types_); + const auto summary = summarize_variable_types(variable_types_); + problem_category_ = summary.category; + has_semi_continuous_variables_ = summary.has_semi_continuous; if (model.has_quadratic_constraints()) { move_quadratic_constraints_from_model(*this, model.quadratic_constraints_); diff --git a/cpp/src/pdlp/cuopt_c.cpp b/cpp/src/pdlp/cuopt_c.cpp index d1b6d3ac59..05e972ad1b 100644 --- a/cpp/src/pdlp/cuopt_c.cpp +++ b/cpp/src/pdlp/cuopt_c.cpp @@ -125,7 +125,9 @@ bool is_int_attribute(cuopt_int_t attribute) case CUOPT_ATTR_PROBLEM_CATEGORY: case CUOPT_ATTR_IS_MIP: case CUOPT_ATTR_HAS_QUADRATIC_OBJECTIVE: - case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: return true; + case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: + case CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS: + case CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS: return true; default: return false; } } @@ -726,71 +728,40 @@ void cuOptDestroyProblem(cuOptOptimizationProblem* problem_ptr) cuopt_int_t cuOptGetNumConstraints(cuOptOptimizationProblem problem, cuopt_int_t* num_constraints_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_constraints_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_constraints_ptr = problem_and_stream_view->get_problem()->get_n_constraints(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_CONSTRAINTS, num_constraints_ptr); } cuopt_int_t cuOptGetNumVariables(cuOptOptimizationProblem problem, cuopt_int_t* num_variables_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_variables_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_variables_ptr = problem_and_stream_view->get_problem()->get_n_variables(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_VARIABLES, num_variables_ptr); } cuopt_int_t cuOptGetObjectiveSense(cuOptOptimizationProblem problem, cuopt_int_t* objective_sense_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_sense_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *objective_sense_ptr = - problem_and_stream_view->get_problem()->get_sense() ? CUOPT_MAXIMIZE : CUOPT_MINIMIZE; - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_OBJECTIVE_SENSE, objective_sense_ptr); } cuopt_int_t cuOptGetObjectiveOffset(cuOptOptimizationProblem problem, cuopt_float_t* objective_offset_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_offset_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *objective_offset_ptr = problem_and_stream_view->get_problem()->get_objective_offset(); - return CUOPT_SUCCESS; + return cuOptGetProblemFloatAttribute(problem, CUOPT_ATTR_OBJECTIVE_OFFSET, objective_offset_ptr); } cuopt_int_t cuOptGetObjectiveCoefficients(cuOptOptimizationProblem problem, cuopt_float_t* objective_coefficients_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (objective_coefficients_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - - cuopt_int_t size = problem_and_stream_view->get_problem()->get_n_variables(); - problem_and_stream_view->get_problem()->copy_objective_coefficients_to_host( - objective_coefficients_ptr, size); - - return CUOPT_SUCCESS; + cuopt_int_t size = 0; + cuopt_int_t status = cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_VARIABLES, &size); + if (status != CUOPT_SUCCESS) { return status; } + return cuOptGetProblemFloatArrayAttribute( + problem, CUOPT_ARRAY_ATTR_OBJECTIVE_COEFFICIENTS, objective_coefficients_ptr, size); } cuopt_int_t cuOptGetNumNonZeros(cuOptOptimizationProblem problem, cuopt_int_t* num_non_zero_elements_ptr) { - if (problem == nullptr) { return CUOPT_INVALID_ARGUMENT; } - if (num_non_zero_elements_ptr == nullptr) { return CUOPT_INVALID_ARGUMENT; } - problem_and_stream_view_t* problem_and_stream_view = - static_cast(problem); - *num_non_zero_elements_ptr = problem_and_stream_view->get_problem()->get_nnz(); - return CUOPT_SUCCESS; + return cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_NONZEROS, num_non_zero_elements_ptr); } cuopt_int_t cuOptGetConstraintMatrix(cuOptOptimizationProblem problem, @@ -1480,7 +1451,10 @@ cuopt_int_t cuOptGetProblemIntAttribute(cuOptOptimizationProblem problem, auto* iface = get_iface(problem); switch (attribute) { case CUOPT_ATTR_NUM_VARIABLES: *value_out = iface->get_n_variables(); return CUOPT_SUCCESS; - case CUOPT_ATTR_NUM_CONSTRAINTS: *value_out = iface->get_n_constraints(); return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_CONSTRAINTS: + *value_out = iface->get_n_constraints() + + static_cast(iface->get_quadratic_constraints().size()); + return CUOPT_SUCCESS; case CUOPT_ATTR_NUM_NONZEROS: *value_out = iface->get_nnz(); return CUOPT_SUCCESS; case CUOPT_ATTR_NUM_INTEGERS: *value_out = iface->get_n_integers(); return CUOPT_SUCCESS; case CUOPT_ATTR_OBJECTIVE_SENSE: @@ -1501,6 +1475,12 @@ cuopt_int_t cuOptGetProblemIntAttribute(cuOptOptimizationProblem problem, case CUOPT_ATTR_HAS_QUADRATIC_CONSTRAINTS: *value_out = iface->has_quadratic_constraints() ? 1 : 0; return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS: + *value_out = iface->get_n_constraints(); + return CUOPT_SUCCESS; + case CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS: + *value_out = static_cast(iface->get_quadratic_constraints().size()); + return CUOPT_SUCCESS; default: return CUOPT_INVALID_ARGUMENT; } } diff --git a/cpp/src/pdlp/optimization_problem.cu b/cpp/src/pdlp/optimization_problem.cu index 95457e2556..d4f669a118 100644 --- a/cpp/src/pdlp/optimization_problem.cu +++ b/cpp/src/pdlp/optimization_problem.cu @@ -54,6 +54,8 @@ namespace cuopt::mathematical_optimization { +constexpr size_t host_variable_type_summary_limit = 50'000; + template optimization_problem_t::optimization_problem_t(raft::handle_t const* handle_ptr) : handle_ptr_(handle_ptr), @@ -101,6 +103,7 @@ optimization_problem_t::optimization_problem_t( objective_name_{other.get_objective_name()}, problem_name_{other.get_problem_name()}, problem_category_{other.get_problem_category()}, + has_semi_continuous_variables_{other.has_semi_continuous_variables()}, var_names_{other.get_variable_names()}, row_names_{other.get_row_names()}, quadratic_constraints_{other.get_quadratic_constraints()} @@ -285,14 +288,40 @@ void optimization_problem_t::set_variable_types(const var_t* variable_ variable_types_.resize(size, stream_view_); raft::copy(variable_types_.data(), variable_types, size, stream_view_); - // Auto-detect problem category based on variable types. + // Auto-detect problem category and cache presence of SEMI_CONTINUOUS vars. // SEMI_CONTINUOUS vars will be reformulated into binary + continuous before solving, // so a problem with only SC vars is treated as MIP. - i_t n_discrete = thrust::count_if( - handle_ptr_->get_thrust_policy(), - variable_types_.begin(), - variable_types_.end(), - [] __device__(auto val) { return val == var_t::INTEGER || val == var_t::SEMI_CONTINUOUS; }); + // Prefer host-side for small instances to reduce latency between launch and first-feasible. + i_t n_discrete = 0; + bool has_semi_continuous_variables = false; + if ((size_t)size < host_variable_type_summary_limit) { + const auto h_variable_types = cuopt::host_copy(variable_types_, stream_view_); + for (const var_t val : h_variable_types) { + if (val == var_t::SEMI_CONTINUOUS) { + has_semi_continuous_variables = true; + ++n_discrete; + } else if (val == var_t::INTEGER) { + ++n_discrete; + } + } + } else { + auto is_discrete = [] __host__ __device__(var_t val) { + return val == var_t::INTEGER || val == var_t::SEMI_CONTINUOUS; + }; + auto is_semi_continuous = [] __host__ __device__(var_t val) { + return val == var_t::SEMI_CONTINUOUS; + }; + n_discrete = thrust::count_if(handle_ptr_->get_thrust_policy(), + variable_types_.begin(), + variable_types_.end(), + is_discrete); + has_semi_continuous_variables = + thrust::count_if(handle_ptr_->get_thrust_policy(), + variable_types_.begin(), + variable_types_.end(), + is_semi_continuous) > 0; + } + has_semi_continuous_variables_ = has_semi_continuous_variables; if (n_discrete == size) { problem_category_ = problem_category_t::IP; } else if (n_discrete > 0) { @@ -580,6 +609,12 @@ problem_category_t optimization_problem_t::get_problem_category() cons return problem_category_; } +template +bool optimization_problem_t::has_semi_continuous_variables() const noexcept +{ + return has_semi_continuous_variables_; +} + template const std::vector& optimization_problem_t::get_variable_names() const { diff --git a/cpp/src/pdlp/solve.cu b/cpp/src/pdlp/solve.cu index 80b3da2c18..b8cb4aead8 100644 --- a/cpp/src/pdlp/solve.cu +++ b/cpp/src/pdlp/solve.cu @@ -1883,6 +1883,12 @@ optimization_problem_solution_t solve_qcqp( CUOPT_LOG_INFO("Dual variables for problems with quadratic constraints not returned."); const f_t nan_val = std::numeric_limits::quiet_NaN(); auto stream = op_problem.get_handle_ptr()->get_stream(); + // solve_qcqp() reformulates quadratic constraints into second-order cones, which grows + // the internal row/column count beyond the documented num_constraints/num_variables. + // Resize back down to the documented lengths. + solution.get_dual_solution().resize( + op_problem.get_n_constraints() + op_problem.get_quadratic_constraints().size(), stream); + solution.get_reduced_cost().resize(op_problem.get_n_variables(), stream); thrust::fill(rmm::exec_policy(stream), solution.get_dual_solution().begin(), solution.get_dual_solution().end(), diff --git a/cpp/src/routing/adapters/adapted_generator.cu b/cpp/src/routing/adapters/adapted_generator.cu index 073be1ff1e..da4027add2 100644 --- a/cpp/src/routing/adapters/adapted_generator.cu +++ b/cpp/src/routing/adapters/adapted_generator.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -72,7 +72,7 @@ void generate_tsp_solution(adapted_sol_t& sol, for (i_t i = 0; i < (i_t)node_infos.size(); ++i) { node_infos[i] = sol.problem->get_node_info_of_node(i + sol.problem->order_info.depot_included_); } - std::mt19937 rng(seed_generator::get_seed()); + std::mt19937 rng(sol.problem->seed_gen.get_seed()); std::shuffle(node_infos.begin(), node_infos.end(), rng); std::vector>>> routes_to_add; routes_to_add.push_back({0, node_infos}); diff --git a/cpp/src/routing/adapters/adapted_modifier.cu b/cpp/src/routing/adapters/adapted_modifier.cu index b5f16ccbbd..4675581a6d 100644 --- a/cpp/src/routing/adapters/adapted_modifier.cu +++ b/cpp/src/routing/adapters/adapted_modifier.cu @@ -1,11 +1,11 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ -#include +#include #include "../diversity/helpers.hpp" #include "../ges/guided_ejection_search.cuh" @@ -77,7 +77,7 @@ void adapted_modifier_t::add_unserviced_request( auto gpu_weight = get_cuopt_cost(final_weight); resource.ls.set_active_weights(gpu_weight, std::numeric_limits::max()); adapted_solution.sol.populate_ep_with_unserved(resource.ges.EP); - resource.ges.EP.random_shuffle(); + resource.ges.EP.random_shuffle(adapted_solution.sol.problem_ptr->seed_gen.get_seed()); resource.ges.squeeze_all_ep(); adapted_solution.populate_host_data(); adapted_solution.check_device_host_coherence(); @@ -101,7 +101,7 @@ void adapted_modifier_t::add_selected_unserviced_requests( auto gpu_weight = get_cuopt_cost(final_weight); resource.ls.set_active_weights(gpu_weight, std::numeric_limits::max()); adapted_solution.sol.populate_ep_with_selected_unserved(resource.ges.EP, unserviced_nodes); - resource.ges.EP.random_shuffle(); + resource.ges.EP.random_shuffle(adapted_solution.sol.problem_ptr->seed_gen.get_seed()); resource.ges.squeeze_all_ep(); adapted_solution.populate_host_data(); adapted_solution.check_device_host_coherence(); diff --git a/cpp/src/routing/diversity/diverse_solver.hpp b/cpp/src/routing/diversity/diverse_solver.hpp index ccbb2c989e..67bf300084 100644 --- a/cpp/src/routing/diversity/diverse_solver.hpp +++ b/cpp/src/routing/diversity/diverse_solver.hpp @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -11,7 +11,7 @@ #include "helpers.hpp" #include "population.hpp" -#include +#include #include #include "../crossovers/dispose.hpp" #include "../crossovers/eax_recombiner.hpp" @@ -245,7 +245,7 @@ struct solve { temp_pair(solution{p_, pool_allocator_.sol_handles[0].get()}, solution{p_, pool_allocator_.sol_handles[0].get()}), f(file_name), - rng(seed_generator::get_seed()), + rng(p->seed_gen.get_seed()), timer(timer_), improvement_timer(timer_), perturbation_count(0) diff --git a/cpp/src/routing/ges/compute_delivery_insertions.cuh b/cpp/src/routing/ges/compute_delivery_insertions.cuh index 666919c902..8731743eb1 100644 --- a/cpp/src/routing/ges/compute_delivery_insertions.cuh +++ b/cpp/src/routing/ges/compute_delivery_insertions.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -15,7 +15,7 @@ #include #include -#include +#include namespace cuopt { namespace routing { diff --git a/cpp/src/routing/ges/eject_until_feasible.cu b/cpp/src/routing/ges/eject_until_feasible.cu index 6de2380870..5a05bde062 100644 --- a/cpp/src/routing/ges/eject_until_feasible.cu +++ b/cpp/src/routing/ges/eject_until_feasible.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -365,8 +365,8 @@ void solution_t::eject_until_feasible(bool add_slack_to_sol) bool is_set = set_shmem_of_kernel(eject_until_feasible_kernel, sh_size); cuopt_assert(is_set, "Not enough shared memory on device for get_all_feasible_insertion!"); cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); - eject_until_feasible_kernel - <<>>(view(), add_slack_to_sol, seed_generator::get_seed()); + eject_until_feasible_kernel<<>>( + view(), add_slack_to_sol, problem_ptr->seed_gen.get_seed()); compute_cost(); global_runtime_checks(false, true, "eject_until_feasible"); } @@ -385,7 +385,7 @@ void solution_t::populate_ep_with_unserved( EP.index_ = ep_index_out.value(stream); stream.synchronize(); if (EP.size() > 1) { - thrust::default_random_engine g(seed_generator::get_seed()); + thrust::default_random_engine g(problem_ptr->seed_gen.get_seed()); thrust::shuffle( sol_handle->get_thrust_policy(), EP.stack_.begin(), EP.stack_.begin() + EP.size(), g); } @@ -405,7 +405,7 @@ void solution_t::populate_ep_with_selected_unserved( raft::device_span(unserviced_device.data(), unserviced_device.size()); populate_ep_with_selected_unserved_kernel<<<1, TPB, 0, stream>>>( - view(), unserviced_view, EP.view(), ep_index_out.data(), seed_generator::get_seed()); + view(), unserviced_view, EP.view(), ep_index_out.data(), problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(stream); EP.index_ = ep_index_out.value(stream); stream.synchronize(); diff --git a/cpp/src/routing/ges/ejection_pool.cuh b/cpp/src/routing/ges/ejection_pool.cuh index 061b5ffb88..afd566f475 100644 --- a/cpp/src/routing/ges/ejection_pool.cuh +++ b/cpp/src/routing/ges/ejection_pool.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -10,8 +10,8 @@ #include "../node/node.cuh" #include +#include #include -#include #include #include @@ -56,13 +56,14 @@ struct ejection_pool_t { void push_back_last() { ++index_; } - void random_shuffle() + // The seed is supplied by the caller: the pool has no route back to the problem + // that owns the seed source. + void random_shuffle(int64_t seed) { // replace with thrust shuffle // how to get sol_handle::get_thrust_policy? if (size() > 1) - device_random_shuffle - <<<1, 1, 0, stream_>>>(stack_.data(), size(), seed_generator::get_seed()); + device_random_shuffle<<<1, 1, 0, stream_>>>(stack_.data(), size(), seed); } bool empty() const diff --git a/cpp/src/routing/ges/execute_insertion.cu b/cpp/src/routing/ges/execute_insertion.cu index dbcfc61250..ddec22acee 100644 --- a/cpp/src/routing/ges/execute_insertion.cu +++ b/cpp/src/routing/ges/execute_insertion.cu @@ -7,8 +7,8 @@ #include "../solution/solution.cuh" +#include #include -#include #include "compute_delivery_insertions.cuh" #include "compute_fragment_ejections.cuh" #include "ejection_pool.cuh" @@ -281,7 +281,7 @@ bool guided_ejection_search_t::execute_best_insertion_ejectio solution_ptr->get_num_orders(), solution_ptr->problem_ptr->get_max_break_dimensions(), solution_ptr->get_n_routes()); - int64_t seed = seed_generator::get_seed(); + int64_t seed = solution_ptr->problem_ptr->seed_gen.get_seed(); i_t* p_scores = p_scores_.data(); i_t fragment_size_arg = fragment_size; i_t fragment_step_arg = fragment_step; @@ -406,7 +406,7 @@ i_t guided_ejection_search_t::find_single_insertion( solution_ptr->get_num_orders(), solution_ptr->problem_ptr->get_max_break_dimensions(), solution_ptr->get_n_routes()), - seed_generator::get_seed()); + solution_ptr->problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(solution_ptr->sol_handle->get_stream()); diff --git a/cpp/src/routing/ges/guided_ejection_search.cu b/cpp/src/routing/ges/guided_ejection_search.cu index 442e7b2b67..1e88375a92 100644 --- a/cpp/src/routing/ges/guided_ejection_search.cu +++ b/cpp/src/routing/ges/guided_ejection_search.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -62,7 +62,7 @@ guided_ejection_search_t::guided_ejection_search_t( (solution.get_num_orders() + solution.problem_ptr->get_max_break_dimensions()), solution.sol_handle->get_stream()), feasible_candidates_size_(solution.sol_handle->get_stream()), - gen_candidate(seed_generator::get_seed()), + gen_candidate(solution.problem_ptr->seed_gen.get_seed()), p_scores_(solution.get_num_orders(), solution.sol_handle->get_stream()), inserted_requests(solution.get_num_orders(), solution.sol_handle->get_stream()), best_squeeze_per_cand(solution.get_num_requests(), solution.sol_handle->get_stream()), @@ -192,7 +192,7 @@ void guided_ejection_search_t::shuffle_pool() raft::common::nvtx::range fun_scope("shuffle_pool"); // include the ejected request in shuffle ++EP.index_; - EP.random_shuffle(); + EP.random_shuffle(solution_ptr->problem_ptr->seed_gen.get_seed()); --EP.index_; if (dump_intermediate) { dump_to_file("Shuffle"); } } @@ -439,7 +439,7 @@ bool guided_ejection_search_t::construct_feasible_solution() } solution_ptr->add_routes(new_routes); // permutate the EP for randomness - EP.random_shuffle(); + EP.random_shuffle(solution_ptr->problem_ptr->seed_gen.get_seed()); bool all_inserted = greedy_insert(); if (!all_inserted) { local_search_ptr_->perturb_solution(*solution_ptr); } diff --git a/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu b/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu index fa3a62d482..8be74cd348 100644 --- a/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu +++ b/cpp/src/routing/ges/lexicographic_search/lexicographic_search.cu @@ -13,7 +13,7 @@ #include "lexicographic_search.cuh" #include -#include +#include #include "raft/core/span.hpp" #include "raft/random/device/sample.cuh" diff --git a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh index 0f0263261e..3fa2c1fbf5 100644 --- a/cpp/src/routing/ges/lexicographic_search/node_stack.cuh +++ b/cpp/src/routing/ges/lexicographic_search/node_stack.cuh @@ -13,7 +13,7 @@ #include "../../solution/solution.cuh" #include -#include +#include #include "raft/core/span.hpp" diff --git a/cpp/src/routing/local_search/compute_insertions.cu b/cpp/src/routing/local_search/compute_insertions.cu index fa2da01aef..1f69065446 100644 --- a/cpp/src/routing/local_search/compute_insertions.cu +++ b/cpp/src/routing/local_search/compute_insertions.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -9,7 +9,7 @@ #include "compute_insertions.cuh" #include "delivery_insertion.cuh" -#include +#include #include "routing/utilities/cuopt_utils.cuh" #include "../routing_helpers.cuh" @@ -831,7 +831,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } else { // for cross the load-balance factor is always 4 move_candidates.number_of_blocks_per_ls_route = @@ -847,7 +847,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } else if (search_type == search_type_t::RANDOM) { // we don't search for relocates in random. n_blocks = sol.get_num_requests(); @@ -859,7 +859,7 @@ void find_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); } } RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); @@ -892,7 +892,7 @@ void find_unserviced_insertions(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); find_insertions_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); sol.sol_handle->sync_stream(); } diff --git a/cpp/src/routing/local_search/fill_gpu_graph.cu b/cpp/src/routing/local_search/fill_gpu_graph.cu index b0fb123824..5cb0e6c81e 100644 --- a/cpp/src/routing/local_search/fill_gpu_graph.cu +++ b/cpp/src/routing/local_search/fill_gpu_graph.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -8,7 +8,7 @@ #include "../solution/solution.cuh" #include "local_search.cuh" -#include +#include #include "../util_kernels/top_k.cuh" #include "cycle_finder/cycle_graph.hpp" #include "routing/utilities/cuopt_utils.cuh" @@ -159,7 +159,7 @@ void local_search_t::fill_gpu_graph(solution_tget_stream(); move_candidates.graph.special_index = solution.get_num_orders() + solution.n_routes; fill_intra_candidates<<>>( - solution.view(), move_candidates.view(), seed_generator::get_seed()); + solution.view(), move_candidates.view(), solution.problem_ptr->seed_gen.get_seed()); // +1 for special node i_t n_blocks = solution.get_num_requests() + 1; fill_graph_kernel diff --git a/cpp/src/routing/local_search/permutation_helper.cuh b/cpp/src/routing/local_search/permutation_helper.cuh index cc1bc37cb1..d590af946e 100644 --- a/cpp/src/routing/local_search/permutation_helper.cuh +++ b/cpp/src/routing/local_search/permutation_helper.cuh @@ -1,13 +1,13 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ #pragma once -#include +#include #include "../node/node.cuh" #include "../route/route.cuh" #include "../routing_helpers.cuh" diff --git a/cpp/src/routing/local_search/random_cross.cu b/cpp/src/routing/local_search/random_cross.cu index a54853513f..7d90c96eb6 100644 --- a/cpp/src/routing/local_search/random_cross.cu +++ b/cpp/src/routing/local_search/random_cross.cu @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -204,7 +204,7 @@ void select_random_route_pairs(solution_t& sol, } select_random_route_pairs_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); } @@ -217,7 +217,7 @@ void pick_random_move_per_route_pair(solution_t& sol, auto nblocks = (n_route_pair + nthreads - 1) / nthreads; pick_random_move_per_route_pair_kernel <<get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); RAFT_CHECK_CUDA(sol.sol_handle->get_stream()); } diff --git a/cpp/src/routing/local_search/vrp/vrp_execute.cu b/cpp/src/routing/local_search/vrp/vrp_execute.cu index 5e417a9345..d65ec4fb36 100644 --- a/cpp/src/routing/local_search/vrp/vrp_execute.cu +++ b/cpp/src/routing/local_search/vrp/vrp_execute.cu @@ -394,7 +394,7 @@ i_t extract_non_overlapping_moves(solution_t& sol, cuopt_expects(is_set, error_type_t::OutOfMemoryError, "Not enough shared memory on device"); extract_non_overlapping_moves_kernel <<<1, TPB, sh_size, sol.sol_handle->get_stream()>>>( - sol.view(), move_candidates.view(), seed_generator::get_seed()); + sol.view(), move_candidates.view(), sol.problem_ptr->seed_gen.get_seed()); return move_candidates.vrp_move_candidates.n_of_selected_moves.value( sol.sol_handle->get_stream()); } diff --git a/cpp/src/routing/problem/problem.cu b/cpp/src/routing/problem/problem.cu index 4335b93734..6868736fc3 100644 --- a/cpp/src/routing/problem/problem.cu +++ b/cpp/src/routing/problem/problem.cu @@ -11,7 +11,7 @@ #include -#include +#include namespace cuopt { namespace routing { namespace detail { @@ -77,8 +77,14 @@ problem_t::problem_t(const data_model_view_t& data_model_vie initialize_incompatible(problem_ref); } - seed_generator::set_seed( - order_info.get_num_requests(), order_info.get_num_orders(), order_info.get_num_orders()); + // A user-supplied seed wins; otherwise derive one from the problem so that a given + // problem still reproduces run to run, which is the historical behaviour. + if (solver_settings_ptr != nullptr && solver_settings_ptr->get_seed() >= 0) { + seed_gen.set_seed(solver_settings_ptr->get_seed()); + } else { + seed_gen.set_seed( + order_info.get_num_requests(), order_info.get_num_orders(), order_info.get_num_orders()); + } } template diff --git a/cpp/src/routing/problem/problem.cuh b/cpp/src/routing/problem/problem.cuh index c2f00bf9f4..46b6c4151b 100644 --- a/cpp/src/routing/problem/problem.cuh +++ b/cpp/src/routing/problem/problem.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2023-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -18,7 +18,7 @@ #include #include #include -#include +#include #include @@ -267,6 +267,10 @@ class problem_t { const data_model_view_t* data_view_ptr; const solver_settings_t* solver_settings_ptr; + // Seed source for this problem. Seeded in the constructor from the solver settings, or + // derived from the problem when the user has not supplied one. + seed_generator_t seed_gen; + i_t get_num_orders() const; i_t get_num_requests() const; diff --git a/cpp/src/routing/solution/solution.cu b/cpp/src/routing/solution/solution.cu index edd3bef9a4..cbf7ed9384 100644 --- a/cpp/src/routing/solution/solution.cu +++ b/cpp/src/routing/solution/solution.cu @@ -93,11 +93,10 @@ void solution_t::add_route(route_t&& route route.n_nodes.set_value_async(n_nodes, sol_handle->get_stream()); route.route_id.set_value_async(route_id, sol_handle->get_stream()); sol_handle->sync_stream(); - i_t route_slot = route_id_to_idx[route_id]; - routes[route_slot] = std::move(route); - const auto route_view = routes[route_slot].view(); + i_t route_slot = route_id_to_idx[route_id]; + routes[route_slot] = std::move(route); cuopt_assert(route_id < (int)routes_view.size(), "route id should be in range"); - routes_view.set_element_async(route_id, route_view, sol_handle->get_stream()); + set_route_views(route_id, route_id + 1); if (max_nodes_per_route < get_route(route_id).max_nodes_per_route()) { resize_routes(raft::alignTo(get_route(route_id).max_nodes_per_route(), base_route_size)); } @@ -122,6 +121,14 @@ void solution_t::add_routes( n_routes += added_routes; check_and_allocate_routes(n_routes); + // Host sources of the async copies below must stay valid and unmodified until the + // sync_stream() after this loop, so per-iteration locals cannot be used. vehicle_id and + // route are references into new_routes and already outlive this function; the scalars + // are staged here, reserved up front so no reallocation can invalidate a pending copy. + std::vector h_n_nodes; + std::vector h_route_ids; + h_n_nodes.reserve(new_routes.size()); + h_route_ids.reserve(new_routes.size()); for (const auto& [vehicle_id, route] : new_routes) { // depot for the beginning and the end i_t new_route_size = route.size() + 2; @@ -131,26 +138,22 @@ void solution_t::add_routes( std::max(max_nodes_per_route, raft::alignTo(new_route_size, base_route_size)); resize_routes(new_route_size); } - std::vector> node_info_h; - for (size_t x = 0; x < route.size(); ++x) { - // Dummy, will be overriden in set_nodes_data_of_route - node_info_h.emplace_back(route[x]); - } - // skip depot + // Values are overridden in set_nodes_data_of_route; copy straight out of new_routes, + // which outlives this function, rather than through a temporary. Skip the depot. raft::copy(d_route.dimensions.requests.node_info.data() + 1, - node_info_h.data(), - node_info_h.size(), + route.data(), + route.size(), sol_handle->get_stream()); - const i_t n_nodes = route.size() + 1; + const i_t& n_nodes = h_n_nodes.emplace_back(route.size() + 1); d_route.n_nodes.set_value_async(n_nodes, sol_handle->get_stream()); - d_route.route_id.set_value_async(route_id, sol_handle->get_stream()); + const i_t& stable_route_id = h_route_ids.emplace_back(route_id); + d_route.route_id.set_value_async(stable_route_id, sol_handle->get_stream()); d_route.vehicle_id.set_value_async(vehicle_id, sol_handle->get_stream()); - i_t route_slot = route_id_to_idx[route_id]; - const auto route_view = routes[route_slot].view(); cuopt_assert(route_id < (int)routes_view.size(), "route id should be in range"); - routes_view.set_element_async(route_id, route_view, sol_handle->get_stream()); ++route_id; } + // Publish once, through the single path that owns the lifetime rule. + set_route_views(prev_route_size, n_routes); set_nodes_data_of_new_routes(added_routes, prev_route_size); sol_handle->sync_stream(); } @@ -286,18 +289,32 @@ void solution_t::set_routes_to_search() } template -void solution_t::set_route_views() +void solution_t::set_route_views(i_t start, i_t end) { raft::common::nvtx::range fun_scope("set_route_views"); if (routes.size() > routes_view.size()) { // reserve with the max size routes_view.resize(routes.size(), sol_handle->get_stream()); } - - for (size_t i = 0; i < routes.size(); ++i) { - const auto route_view = get_route(i).view(); - routes_view.set_element_async(i, route_view, sol_handle->get_stream()); + if (end < 0) { end = (i_t)routes.size(); } + cuopt_assert(start >= 0 && end <= (i_t)routes.size(), "route view range out of bounds"); + if (end <= start) { return; } + + // Single point where route views are published to the device. The host source of an async + // copy must stay valid and unmodified until the stream is synchronized -- rmm's + // memcpy_async uses cudaMemcpySrcAccessOrderStream on CUDA 13, so the bytes are read when + // the copy runs, not when it is enqueued. Staging in a member buffer and synchronizing here + // keeps that rule in one place instead of at every call site. + h_routes_view.clear(); + h_routes_view.reserve(end - start); + for (i_t i = start; i < end; ++i) { + h_routes_view.push_back(get_route(i).view()); } + raft::copy(routes_view.data() + start, + h_routes_view.data(), + h_routes_view.size(), + sol_handle->get_stream()); + sol_handle->sync_stream(); } // init the rotues with the desired number of routes @@ -374,14 +391,17 @@ void solution_t::resize_routes(i_t new_size) { raft::common::nvtx::range fun_scope("resize_routes"); + bool any_resized = false; for (i_t i = 0; i < n_routes; ++i) { auto& route_i = get_route(i); if (route_i.max_nodes_per_route() < new_size) { route_i.resize(new_size); - const auto route_view = get_route(i).view(); - routes_view.set_element_async(i, route_view, sol_handle->get_stream()); + any_resized = true; } } + // A resize reallocates the route buffers, so the device-side views are stale. Republish + // through set_route_views rather than copying each changed view from a local here. + if (any_resized) { set_route_views(); } max_nodes_per_route = std::max(max_nodes_per_route, new_size); } @@ -512,10 +532,8 @@ void solution_t::copy_device_solution(solution_tget_stream()); - } + // copy_routes below reads these entries, so they must be published before it launches. + set_route_views(n_routes, src_sol.n_routes); n_routes = src_sol.n_routes; diff --git a/cpp/src/routing/solution/solution.cuh b/cpp/src/routing/solution/solution.cuh index 143414e69e..db00d09147 100644 --- a/cpp/src/routing/solution/solution.cuh +++ b/cpp/src/routing/solution/solution.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2022-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2022-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -494,7 +494,8 @@ class solution_t { size_t get_temp_route_shared_size(i_t added_size = 0) const; void compute_initial_data(bool check_feasibility = true); void random_init_routes(); - void set_route_views(); + // Publishes routes[start, end) to the device. end < 0 means routes.size(). + void set_route_views(i_t start = 0, i_t end = -1); void expand_route(i_t route_id); void resize_route(i_t route_id, i_t new_route_size); void clear_routes(std::vector vehicle_ids); @@ -650,6 +651,11 @@ class solution_t { // we shouldn't access this directly as route ids map differently std::vector> routes; + // Host staging for routes_view, internal to set_route_views(). That function synchronizes + // before returning, so no copy is ever left pending against this buffer and it is safe to + // reuse across calls. Kept as a member so the paths that publish views do not allocate. + std::vector::view_t> h_routes_view; + public: static constexpr i_t fragment_step = 1; diff --git a/cpp/src/routing/solver_settings.cu b/cpp/src/routing/solver_settings.cu index 6267f39698..334a10638c 100644 --- a/cpp/src/routing/solver_settings.cu +++ b/cpp/src/routing/solver_settings.cu @@ -38,6 +38,12 @@ void solver_settings_t::dump_best_results(const std::string& file_path best_result_file_name_ = file_path; } +template +void solver_settings_t::set_seed(i_t seed) +{ + seed_ = seed; +} + template f_t solver_settings_t::get_time_limit() const noexcept { @@ -63,6 +69,12 @@ std::tuple solver_settings_t::get_dump_best_re return std::make_tuple(dump_interval_, dump_best_results_, best_result_file_name_); } +template +i_t solver_settings_t::get_seed() const noexcept +{ + return seed_; +} + template class CUOPT_EXPORT solver_settings_t; } // namespace routing } // namespace cuopt diff --git a/cpp/src/routing/utilities/cuopt_utils.cuh b/cpp/src/routing/utilities/cuopt_utils.cuh index 41900ceebe..f94bc493c2 100644 --- a/cpp/src/routing/utilities/cuopt_utils.cuh +++ b/cpp/src/routing/utilities/cuopt_utils.cuh @@ -1,6 +1,6 @@ /* clang-format off */ /* - * SPDX-FileCopyrightText: Copyright (c) 2021-2025, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-FileCopyrightText: Copyright (c) 2021-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. * SPDX-License-Identifier: Apache-2.0 */ /* clang-format on */ @@ -10,7 +10,7 @@ #include "routing/utilities/constants.hpp" #include -#include +#include #include #include diff --git a/cpp/src/routing/utilities/seed_generator.cuh b/cpp/src/routing/utilities/seed_generator.cuh new file mode 100644 index 0000000000..172ec613f9 --- /dev/null +++ b/cpp/src/routing/utilities/seed_generator.cuh @@ -0,0 +1,91 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2023-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once +#include +#include + +#include +#include +#include + +namespace cuopt { +namespace routing { + +namespace detail { + +// Folds several values into one seed using the Cantor pairing function. +// +// The arithmetic is done in uint64_t: routing folds `int` problem dimensions, and the +// product overflows a 32-bit int once two equal dimensions reach 181. Signed overflow is +// undefined behaviour, so widen first and let the unsigned type wrap deterministically. +template +inline int64_t fold_seed(seed_t seed) +{ + return static_cast(static_cast(seed)); +} + +template +inline int64_t fold_seed(arg0 seed0, arg1 seed1, args... seeds) +{ + const uint64_t a = static_cast(seed0); + const uint64_t b = static_cast(seed1); + const uint64_t sum = a + b; + return fold_seed(b + sum * (sum + 1) / 2, seeds...); +} + +} // namespace detail + +/** + * @brief Routing's source of deterministic seeds, owned by the problem that uses it. + * + * `problem_t` holds one of these, seeded from the user's `solver_settings_t::set_seed` or, + * when none was given, from the problem's own dimensions. Routing previously drew from a + * process-wide counter shared with the MIP heuristics, so whichever solver constructed its + * problem last overwrote the other's seed. + * + * The counter is `mutable` and atomic so that `get_seed()` can be `const`: `solution_t` + * reaches its problem through a `const` pointer, and drawing a seed does not change the + * problem's logical state. Concurrent callers are handed distinct values, but the order in + * which they receive them is not fixed, so reproducibility still requires a deterministic + * call order. + */ +class seed_generator_t { + mutable std::atomic counter_{0}; + + public: + seed_generator_t() = default; + explicit seed_generator_t(int64_t initial) : counter_(initial) {} + + // std::atomic is neither copyable nor movable, which would delete problem_t's defaulted + // move constructor. Transfer the value instead so the owning problem stays movable. + seed_generator_t(seed_generator_t&& other) noexcept + : counter_(other.counter_.load(std::memory_order_relaxed)) + { + } + + seed_generator_t& operator=(seed_generator_t&& other) noexcept + { + counter_.store(other.counter_.load(std::memory_order_relaxed), std::memory_order_relaxed); + return *this; + } + + template + void set_seed(args... seeds) + { +#ifdef BENCHMARK + counter_.store(static_cast(std::random_device{}()), std::memory_order_relaxed); +#else + counter_.store(detail::fold_seed(seeds...), std::memory_order_relaxed); +#endif + } + + int64_t get_seed() const { return counter_.fetch_add(1, std::memory_order_relaxed); } +}; + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/utilities/integer_scaling.hpp b/cpp/src/utilities/integer_scaling.hpp new file mode 100644 index 0000000000..4456977698 --- /dev/null +++ b/cpp/src/utilities/integer_scaling.hpp @@ -0,0 +1,217 @@ +/* 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 +#include +#include +#include +#include +#include + +#include + +namespace cuopt { + +namespace detail { + +// Best rational approximation p/q to x with q <= max_denom, via continued fractions. Returns the +// last valid convergent if the denominator limit is reached. +inline std::pair rational_approximation(double x, + int64_t max_denom, + double epsilon) +{ + cuopt_assert(std::isfinite(x), "non-finite coefficient"); + if (!std::isfinite(x)) return {0, 0}; + + double ax = std::abs(x); + if (ax < epsilon) { return {0, 1}; } + + if (x < 0) { + auto [p, q] = rational_approximation(-x, max_denom, epsilon); + return {-p, q}; + } + + const double integer_part = std::floor(x); + if (integer_part >= (double)std::numeric_limits::max()) return {0, 0}; + + int64_t p_prev2 = 1, q_prev2 = 0; + int64_t p_prev1 = (int64_t)integer_part, q_prev1 = 1; + + double remainder = x - integer_part; + + for (int iter = 0; iter < 100; ++iter) { + if (std::abs(remainder) < 1e-15) break; + + remainder = 1.0 / remainder; + const double quotient = std::floor(remainder); + if (!std::isfinite(quotient) || quotient >= (double)std::numeric_limits::max()) { + return {0, 0}; + } + int64_t a = (int64_t)quotient; + remainder -= a; + + int64_t p_product; + int64_t q_product; + int64_t p_curr; + int64_t q_curr; + if (__builtin_mul_overflow(a, p_prev1, &p_product) || + __builtin_add_overflow(p_product, p_prev2, &p_curr) || + __builtin_mul_overflow(a, q_prev1, &q_product) || + __builtin_add_overflow(q_product, q_prev2, &q_curr)) { + return {0, 0}; + } + + if (q_curr > max_denom) break; + + p_prev2 = p_prev1; + q_prev2 = q_prev1; + p_prev1 = p_curr; + q_prev1 = q_curr; + + double approx_err = x - (double)p_curr / (double)q_curr; + if (std::abs(approx_err) < epsilon) break; + } + + return {p_prev1, q_prev1}; +} + +// Brute-force: try scalars 1..max_brute and return the smallest that makes all coefficients +// integral. +inline double find_scaling_brute_force(const std::vector& coefficients, + int max_brute = 100, + double tol = 1e-6) +{ + for (int s = 1; s <= max_brute; ++s) { + bool ok = true; + for (double c : coefficients) { + cuopt_assert(std::isfinite(c), "non-finite coefficient"); + if (!std::isfinite(c)) return std::numeric_limits::quiet_NaN(); + double scaled = s * c; + if (!std::isfinite(scaled) || std::abs(scaled - std::round(scaled)) > tol) { + ok = false; + break; + } + } + if (ok) return (double)s; + } + return std::numeric_limits::quiet_NaN(); +} + +} // namespace detail + +// Continued-fractions approach: rationalize each coefficient, compute scm/gcd incrementally. +// Returns the smallest positive multiplier s such that s * c is (near-)integer for every c, or NaN +// if no such multiplier exists within the caps. +inline double find_scaling_rational(const std::vector& coefficients, + double maxscale = 1e6, + int64_t maxdnom = 10000000, + double maxfinal = 10000, + double intcheck_tol = 1e-6) +{ + constexpr double no_scaling = std::numeric_limits::quiet_NaN(); + double epsilon = 1.0 / maxscale; + + int64_t gcd = 0; + int64_t scm = 1; + + for (double c : coefficients) { + auto [num, den] = detail::rational_approximation(c, maxdnom, epsilon); + if (den == 0) return no_scaling; + if (num == 0) continue; + + if (num == std::numeric_limits::min()) return no_scaling; + int64_t abs_num = std::abs(num); + if (gcd == 0) { + gcd = abs_num; + scm = den; + } else { + gcd = std::gcd(gcd, abs_num); + int64_t factor = den / std::gcd(scm, den); + int64_t new_scm; + if (__builtin_mul_overflow(scm, factor, &new_scm)) return no_scaling; + scm = new_scm; + } + + if ((double)scm / (double)gcd > maxscale) return no_scaling; + } + + if (gcd == 0) return 1.0; + + double intscalar = (double)scm / (double)gcd; + if (intscalar > maxfinal) return no_scaling; + + for (double c : coefficients) { + double scaled = intscalar * c; + if (!std::isfinite(scaled) || std::abs(scaled - std::round(scaled)) > intcheck_tol) + return no_scaling; + } + + return intscalar; +} + +// Finds the smallest integer scaling factor s such that s * c_i is integral for all i. Tries a +// brute-force sweep first (cheap, numerically robust), then falls back to continued fractions for +// larger scalars. +inline double find_objective_scaling_factor(const std::vector& coefficients) +{ + double s = detail::find_scaling_brute_force(coefficients); + if (!std::isnan(s)) return s; + return find_scaling_rational(coefficients); +} + +// A bound counts as "infinite" if non-finite or at/above the solver's large-bound sentinel. +template +inline bool scaling_bound_finite(f_t x) +{ + return std::isfinite(x) && std::abs(x) < f_t(1e30); +} + +// An exact subset sum of at most max_len integer terms, plus the bound compare, must stay inside +// the mantissa of the type that holds the sum for it to never round: 2^24 for fp32, 2^53 for fp64. +// Callers store the scaled row back as f_t and sum it as f_t, so the budget follows f_t rather than +// the double used internally to search for the multiplier. +template +inline constexpr double exact_subset_sum_budget = + (double)(uint64_t{1} << std::numeric_limits::digits); + +template +inline double row_int_scale(const f_t* coef, int n, f_t lo, f_t up, int max_len, int64_t scale_cap) +{ + static_assert(std::is_floating_point_v, "row scaling is defined for floating point rows"); + static_assert(std::numeric_limits::digits < 64, "mantissa wider than the budget shift"); + cuopt_assert(n >= 0, "negative row length"); + cuopt_assert(n <= max_len, "row length exceeds the exactness budget length"); + cuopt_assert(scale_cap > 0, "non-positive scale cap"); + + std::vector vals; + vals.reserve(n + 2); + for (int k = 0; k < n; ++k) + vals.push_back((double)coef[k]); + if (scaling_bound_finite(lo)) vals.push_back((double)lo); + if (scaling_bound_finite(up)) vals.push_back((double)up); + + const double scale = find_scaling_rational(vals, + /*maxscale=*/1e12, + /*maxdnom=*/scale_cap, + /*maxfinal=*/(double)scale_cap, + /*intcheck_tol=*/1e-9); + if (!std::isfinite(scale) || scale <= 0.0) return 0.0; + + // guard so the subset sum (<= max_len integer terms) stays within f_t's mantissa + double maxabs = 0.0; + for (double v : vals) + maxabs = std::max(maxabs, std::abs(v * scale)); + if (maxabs * (double)max_len >= exact_subset_sum_budget) return 0.0; + + return scale; +} + +} // namespace cuopt diff --git a/cpp/src/utilities/macros.cuh b/cpp/src/utilities/macros.cuh index d36832015a..380851627f 100644 --- a/cpp/src/utilities/macros.cuh +++ b/cpp/src/utilities/macros.cuh @@ -14,7 +14,7 @@ // 3) heavy #ifdef ASSERT_MODE #include -#define cuopt_assert(val, msg) assert(val&& msg) +#define cuopt_assert(val, msg) assert((val) && msg) #define cuopt_func_call(func) func; #else #define cuopt_assert(val, msg) diff --git a/cpp/src/utilities/pcgenerator.hpp b/cpp/src/utilities/pcgenerator.hpp index 5b4a226fce..021c950ce7 100644 --- a/cpp/src/utilities/pcgenerator.hpp +++ b/cpp/src/utilities/pcgenerator.hpp @@ -85,7 +85,7 @@ class pcgenerator_t { state = oldstate * 6364136223846793005ULL + stream; uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u; uint32_t rot = oldstate >> 59u; - ret = (xorshifted >> rot) | (xorshifted << ((32u - rot) & 31u)); + ret = (xorshifted >> rot) | (xorshifted << ((-rot) & 31u)); return ret; } diff --git a/cpp/src/utilities/version_info.cpp b/cpp/src/utilities/version_info.cpp index 71dfc20c22..3fe1074f87 100644 --- a/cpp/src/utilities/version_info.cpp +++ b/cpp/src/utilities/version_info.cpp @@ -12,135 +12,224 @@ #include #include -#include -#include -#include -#include -#include -#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include namespace cuopt { -static int get_physical_cores() +// Reads up to buf_size-1 bytes, NUL-terminates, strips trailing whitespace/NULs. +// Returns bytes kept (excluding the terminator), or -1 on failure. +static ssize_t read_file_buf(const char* path, char* buf, size_t buf_size) { - std::ifstream cpuinfo("/proc/cpuinfo"); - if (!cpuinfo.is_open()) return 0; - - std::string line; - int physical_id = -1, core_id = -1; - std::set> cores; - - while (std::getline(cpuinfo, line)) { - if (line.find("physical id") != std::string::npos) { - physical_id = std::stoi(line.substr(line.find(":") + 1)); - } else if (line.find("core id") != std::string::npos) { - core_id = std::stoi(line.substr(line.find(":") + 1)); + if (buf_size == 0) return -1; + const int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + const ssize_t n = read(fd, buf, buf_size - 1); + close(fd); + if (n < 0) return -1; + buf[n] = '\0'; + + // Device-tree properties are often NUL-terminated without a trailing newline. + size_t len = 0; + while (len < (size_t)n && buf[len] != '\0') { + ++len; + } + buf[len] = '\0'; + while (len > 0 && + (buf[len - 1] == '\n' || buf[len - 1] == '\r' || buf[len - 1] == ' ' || + buf[len - 1] == '\t')) { + buf[--len] = '\0'; + } + return (ssize_t)len; +} + +// Parses a kernel CPU list ("0-3,8,10-11") into cpus[0..max_cpus). Returns count written. +static int parse_cpu_list(const char* list, int* cpus, int max_cpus) +{ + int count = 0; + const char* p = list; + while (*p && count < max_cpus) { + while (*p == ',' || *p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { + ++p; } + if (*p == '\0') break; + + char* end = nullptr; + const long lo = std::strtol(p, &end, 10); + if (end == p) break; + p = end; - if (physical_id != -1 && core_id != -1) { - cores.insert({physical_id, core_id}); - physical_id = -1; - core_id = -1; + if (*p == '-') { + ++p; + const long hi = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + for (long cpu = lo; cpu <= hi && count < max_cpus; ++cpu) { + cpus[count++] = (int)cpu; + } + } else { + cpus[count++] = (int)lo; } } + return count; +} - if (cores.empty()) { - cpuinfo.clear(); - cpuinfo.seekg(0); - while (std::getline(cpuinfo, line)) { - if (line.find("cpu cores") != std::string::npos) { - return std::stoi(line.substr(line.find(":") + 1)); +static void mark_cpus_from_list(const char* list, char visited[CPU_SETSIZE]) +{ + const char* p = list; + while (*p) { + while (*p == ',' || *p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') { + ++p; + } + if (*p == '\0') break; + + char* end = nullptr; + const long lo = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + + if (*p == '-') { + ++p; + const long hi = std::strtol(p, &end, 10); + if (end == p) break; + p = end; + for (long cpu = lo; cpu <= hi; ++cpu) { + if (cpu >= 0 && cpu < CPU_SETSIZE) { visited[cpu] = 1; } } + } else if (lo >= 0 && lo < CPU_SETSIZE) { + visited[lo] = 1; + } + } +} + +// CPUs this process may run on (respects Slurm/cgroup cpusets, taskset, etc.). +static int get_allowed_cpus(int* cpus, int max_cpus) +{ + cpu_set_t set; + CPU_ZERO(&set); + int count = 0; + if (sched_getaffinity(0, sizeof(set), &set) == 0) { + for (int cpu = 0; cpu < CPU_SETSIZE && count < max_cpus; ++cpu) { + if (CPU_ISSET(cpu, &set)) { cpus[count++] = cpu; } + } + } + if (count > 0) return count; + + char buf[256]; + if (read_file_buf("/sys/devices/system/cpu/online", buf, sizeof(buf)) < 0) return 0; + return parse_cpu_list(buf, cpus, max_cpus); +} + +static int get_physical_cores(const int* allowed_cpus, int allowed_count) +{ + if (allowed_count <= 0) return 0; + + char visited[CPU_SETSIZE]; + std::memset(visited, 0, sizeof(visited)); + int cores = 0; + + for (int i = 0; i < allowed_count; ++i) { + const int cpu = allowed_cpus[i]; + if (cpu < 0 || cpu >= CPU_SETSIZE || visited[cpu]) continue; + + char path[128]; + char buf[256]; + snprintf(path, + sizeof(path), + "/sys/devices/system/cpu/cpu%d/topology/core_cpus_list", + cpu); + ssize_t n = read_file_buf(path, buf, sizeof(buf)); + if (n < 0) { + snprintf(path, + sizeof(path), + "/sys/devices/system/cpu/cpu%d/topology/thread_siblings_list", + cpu); + n = read_file_buf(path, buf, sizeof(buf)); } - return 1; + + if (n >= 0) { + mark_cpus_from_list(buf, visited); + } + visited[cpu] = 1; + ++cores; } - return cores.size(); + + return cores > 0 ? cores : allowed_count; } -static std::string get_cpu_model_from_proc() +static bool copy_stripped(char* dst, size_t dst_size, const char* src) { - std::ifstream cpuinfo("/proc/cpuinfo"); - if (!cpuinfo.is_open()) return ""; - - std::string line; - while (std::getline(cpuinfo, line)) { - std::size_t pos = line.find("model name"); - if (pos == std::string::npos) pos = line.find("Processor"); - if (pos != std::string::npos) { - std::size_t colon = line.find(':', pos); - if (colon != std::string::npos) return line.substr(colon + 2); // Skip ": " + if (dst_size == 0) return false; + size_t len = std::strlen(src); + while (len > 0 && (src[len - 1] == '\n' || src[len - 1] == '\r' || src[len - 1] == ' ')) { + --len; + } + if (len >= dst_size) len = dst_size - 1; + std::memcpy(dst, src, len); + dst[len] = '\0'; + return len > 0; +} + +static bool get_cpu_model_from_proc(char* out, size_t out_size) +{ + FILE* cpuinfo = fopen("/proc/cpuinfo", "r"); + if (cpuinfo == nullptr) return false; + + char line[512]; + while (fgets(line, sizeof(line), cpuinfo) != nullptr) { + const char* field = std::strstr(line, "model name"); + if (field == nullptr) field = std::strstr(line, "Processor"); + if (field == nullptr) continue; + + const char* colon = std::strchr(field, ':'); + if (colon == nullptr) continue; + ++colon; + while (*colon == ' ' || *colon == '\t') { + ++colon; } + const bool ok = copy_stripped(out, out_size, colon); + fclose(cpuinfo); + return ok; } - return ""; + fclose(cpuinfo); + return false; } -// From https://gcc.gnu.org/onlinedocs/gcc/x86-Built-in-Functions.html -// Also supported by clang -static std::string get_cpu_model_builtin() +static void get_cpu_model(char* out, size_t out_size) { -#if (defined(__x86_64__) || defined(__i386__)) && (defined(__GNUC__) || defined(__clang__)) - __builtin_cpu_init(); - return __builtin_cpu_is("amd") ? "AMD CPU" - : __builtin_cpu_is("intel") ? "Intel CPU" - : __builtin_cpu_is("atom") ? "Intel Atom CPU" - : __builtin_cpu_is("slm") ? "Intel Silvermont CPU" - : __builtin_cpu_is("core2") ? "Intel Core 2 CPU" - : __builtin_cpu_is("corei7") ? "Intel Core i7 CPU" - : __builtin_cpu_is("nehalem") ? "Intel Core i7 Nehalem CPU" - : __builtin_cpu_is("westmere") ? "Intel Core i7 Westmere CPU" - : __builtin_cpu_is("sandybridge") ? "Intel Core i7 Sandy Bridge CPU" - : __builtin_cpu_is("ivybridge") ? "Intel Core i7 Ivy Bridge CPU" - : __builtin_cpu_is("haswell") ? "Intel Core i7 Haswell CPU" - : __builtin_cpu_is("broadwell") ? "Intel Core i7 Broadwell CPU" - : __builtin_cpu_is("skylake") ? "Intel Core i7 Skylake CPU" - : __builtin_cpu_is("skylake-avx512") ? "Intel Core i7 Skylake AVX512 CPU" - : __builtin_cpu_is("cannonlake") ? "Intel Core i7 Cannon Lake CPU" - : __builtin_cpu_is("icelake-client") ? "Intel Core i7 Ice Lake Client CPU" - : __builtin_cpu_is("icelake-server") ? "Intel Core i7 Ice Lake Server CPU" - : __builtin_cpu_is("cascadelake") ? "Intel Core i7 Cascadelake CPU" - : __builtin_cpu_is("tigerlake") ? "Intel Core i7 Tigerlake CPU" - : __builtin_cpu_is("cooperlake") ? "Intel Core i7 Cooperlake CPU" - : __builtin_cpu_is("sapphirerapids") ? "Intel Core i7 sapphirerapids CPU" - : __builtin_cpu_is("alderlake") ? "Intel Core i7 Alderlake CPU" - : __builtin_cpu_is("rocketlake") ? "Intel Core i7 Rocketlake CPU" - : __builtin_cpu_is("graniterapids") ? "Intel Core i7 graniterapids CPU" - : __builtin_cpu_is("graniterapids-d") ? "Intel Core i7 graniterapids D CPU" - : __builtin_cpu_is("bonnell") ? "Intel Atom Bonnell CPU" - : __builtin_cpu_is("silvermont") ? "Intel Atom Silvermont CPU" - : __builtin_cpu_is("goldmont") ? "Intel Atom Goldmont CPU" - : __builtin_cpu_is("goldmont-plus") ? "Intel Atom Goldmont Plus CPU" - : __builtin_cpu_is("tremont") ? "Intel Atom Tremont CPU" - : __builtin_cpu_is("sierraforest") ? "Intel Atom Sierra Forest CPU" - : __builtin_cpu_is("grandridge") ? "Intel Atom Grand Ridge CPU" - : __builtin_cpu_is("amdfam10h") ? "AMD Family 10h CPU" - : __builtin_cpu_is("barcelona") ? "AMD Family 10h Barcelona CPU" - : __builtin_cpu_is("shanghai") ? "AMD Family 10h Shanghai CPU" - : __builtin_cpu_is("istanbul") ? "AMD Family 10h Istanbul CPU" - : __builtin_cpu_is("btver1") ? "AMD Family 14h CPU" - : __builtin_cpu_is("amdfam15h") ? "AMD Family 15h CPU" - : __builtin_cpu_is("bdver1") ? "AMD Family 15h Bulldozer version 1" - : __builtin_cpu_is("bdver2") ? "AMD Family 15h Bulldozer version 2" - : __builtin_cpu_is("bdver3") ? "AMD Family 15h Bulldozer version 3" - : __builtin_cpu_is("bdver4") ? "AMD Family 15h Bulldozer version 4" - : __builtin_cpu_is("btver2") ? "AMD Family 16h CPU" - : __builtin_cpu_is("amdfam17h") ? "AMD Family 17h CPU" - : __builtin_cpu_is("znver1") ? "AMD Family 17h Zen version 1" - : __builtin_cpu_is("znver2") ? "AMD Family 17h Zen version 2" - : __builtin_cpu_is("amdfam19h") ? "AMD Family 19h CPU" - : "Unknown"; -#else - return "Unknown"; -#endif + if (get_cpu_model_from_proc(out, out_size)) return; + + char buf[256]; + if (read_file_buf("/sys/firmware/devicetree/base/model", buf, sizeof(buf)) >= 0 || + read_file_buf("/proc/device-tree/model", buf, sizeof(buf)) >= 0) { + if (copy_stripped(out, out_size, buf)) return; + } + if (read_file_buf("/sys/devices/virtual/dmi/id/product_name", buf, sizeof(buf)) >= 0) { + if (copy_stripped(out, out_size, buf)) return; + } + std::snprintf(out, out_size, "Unknown"); } -static std::string get_cpu_model() +static const char* get_simd_target() { - if (auto model_from_proc = get_cpu_model_from_proc(); !model_from_proc.empty()) { - return model_from_proc; - } else if (auto model_from_builtin = get_cpu_model_builtin(); !model_from_builtin.empty()) { - return model_from_builtin; + const int64_t target = hwy::DispatchedTarget(); + switch (target) { + case HWY_AVX3: + case HWY_AVX3_DL: + case HWY_AVX3_ZEN4: + case HWY_AVX3_SPR: + case HWY_AVX10_2: return "AVX-512"; + default: return hwy::TargetName(target); } - return "Unknown"; } struct host_memory_info_t { @@ -150,26 +239,28 @@ struct host_memory_info_t { static host_memory_info_t get_host_memory_info() { - std::ifstream meminfo("/proc/meminfo"); - if (!meminfo.is_open()) return {}; + FILE* meminfo = fopen("/proc/meminfo", "r"); + if (meminfo == nullptr) return {}; - std::string line; + char line[256]; long total_kb = 0; long available_kb = 0; long free_kb = 0; - while (std::getline(meminfo, line)) { - std::istringstream fields(line); - std::string key; + int found = 0; + while (found < 3 && fgets(line, sizeof(line), meminfo) != nullptr) { long value_kb = 0; - fields >> key >> value_kb; - if (key == "MemTotal:") { + if (std::sscanf(line, "MemTotal: %ld", &value_kb) == 1) { total_kb = value_kb; - } else if (key == "MemAvailable:") { + ++found; + } else if (std::sscanf(line, "MemAvailable: %ld", &value_kb) == 1) { available_kb = value_kb; - } else if (key == "MemFree:") { + ++found; + } else if (std::sscanf(line, "MemFree: %ld", &value_kb) == 1) { free_kb = value_kb; + ++found; } } + fclose(meminfo); if (available_kb == 0) { available_kb = free_kb; } constexpr double kb_per_gib = 1024.0 * 1024.0; @@ -193,14 +284,19 @@ void print_version_info(int num_devices) CUOPT_GIT_COMMIT_HASH, CUOPT_CPU_ARCHITECTURE, CUOPT_CUDA_ARCHITECTURES); + const auto memory = get_host_memory_info(); - CUOPT_LOG_INFO( - "CPU: %s, threads (physical/logical): %d/%d, RAM (available/total): %.2f / %.2f GiB", - get_cpu_model().c_str(), - get_physical_cores(), - std::thread::hardware_concurrency(), - memory.available_gb, - memory.total_gb); + int allowed_cpus[CPU_SETSIZE]; + const int allowed_count = get_allowed_cpus(allowed_cpus, CPU_SETSIZE); + char cpu_model[256]; + get_cpu_model(cpu_model, sizeof(cpu_model)); + CUOPT_LOG_INFO("CPU: %s, threads: %dC/%dT, RAM usage: %.2f/%.2fGiB", + cpu_model, + get_physical_cores(allowed_cpus, allowed_count), + allowed_count, + std::max(0.0, memory.total_gb - memory.available_gb), + memory.total_gb); + CUOPT_LOG_INFO("CPU SIMD target: %s", get_simd_target()); for (int device_id = 0; device_id < num_devices; ++device_id) { cudaDeviceProp device_prop{}; diff --git a/cpp/tests/internal/CMakeLists.txt b/cpp/tests/internal/CMakeLists.txt index 2856c56f38..99f26aa440 100644 --- a/cpp/tests/internal/CMakeLists.txt +++ b/cpp/tests/internal/CMakeLists.txt @@ -27,6 +27,8 @@ ConfigureTest(NUMOPT_INTERNAL_TEST ${CUOPT_TEST_DIR}/mip/integer_with_real_bounds.cu ${CUOPT_TEST_DIR}/mip/empty_fixed_problems_test.cu ${CUOPT_TEST_DIR}/mip/presolve_test.cu + ${CUOPT_TEST_DIR}/mip/block_bve_test.cu + ${CUOPT_TEST_DIR}/mip/bhw_coeff_reduce_test.cpp ${CUOPT_TEST_DIR}/mip/gf2_presolve_test.cpp ${CUOPT_TEST_DIR}/mip/termination_test.cu ${CUOPT_TEST_DIR}/mip/determinism_test.cu diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_test.c b/cpp/tests/linear_programming/c_api_tests/c_api_test.c index 31bd18d0ef..16852b95ef 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_test.c +++ b/cpp/tests/linear_programming/c_api_tests/c_api_test.c @@ -2504,6 +2504,193 @@ cuopt_int_t test_mip_solution_lp_methods() return status; } +/** + * Dual recovery + * for QCQP is not supported yet, so solve_qcqp() resizes the dual solution / reduced + * cost vectors down to the documented lengths and fills them with NaN (cuOpt's + * existing "value not computed" convention) rather than leaving them oversized. + */ +cuopt_int_t test_qcqp_solution_dual_methods() +{ + cuOptOptimizationProblem problem = NULL; + cuOptSolverSettings settings = NULL; + cuOptSolution solution = NULL; + cuopt_int_t status; + cuopt_int_t num_constraints; + cuopt_int_t num_linear; + cuopt_int_t num_quadratic; + int i; + + /* + * minimize t + * subject to + * t >= 0 (linear constraint) + * x1^2 + x2^2 - t^2 <= 0 (quadratic constraint) + * t >= -1, x1 >= 3, x2 >= 4 (variable bounds, not counted as constraints) + */ + cuopt_int_t num_linear_constraints = 1; + cuopt_int_t num_variables = 3; + + cuopt_float_t objective[] = {1.0, 0.0, 0.0}; + cuopt_int_t row_offsets[] = {0, 1}; + cuopt_int_t column_indices[] = {0}; + cuopt_float_t matrix_values[] = {1.0}; + char constraint_sense[] = {CUOPT_GREATER_THAN}; + cuopt_float_t rhs[] = {0.0}; + + cuopt_float_t lower_bounds[] = {-1.0, 3.0, 4.0}; + cuopt_float_t upper_bounds[] = {CUOPT_INFINITY, CUOPT_INFINITY, CUOPT_INFINITY}; + char variable_types[] = {CUOPT_CONTINUOUS, CUOPT_CONTINUOUS, CUOPT_CONTINUOUS}; + + cuopt_int_t qc_row[] = {0, 1, 2}; + cuopt_int_t qc_col[] = {0, 1, 2}; + cuopt_float_t qc_coeff[] = {-1.0, 1.0, 1.0}; + + /* Allocated to the documented sizes (queried below). */ + cuopt_float_t* dual_solution = NULL; + cuopt_float_t* reduced_costs = NULL; + + printf("Testing QCQP solution dual/reduced-cost methods...\n"); + + status = cuOptCreateProblem(num_linear_constraints, + num_variables, + CUOPT_MINIMIZE, + 0.0, + objective, + row_offsets, + column_indices, + matrix_values, + constraint_sense, + rhs, + lower_bounds, + upper_bounds, + variable_types, + &problem); + if (status != CUOPT_SUCCESS) { + printf("Error creating QCQP problem: %d\n", status); + goto DONE; + } + + status = cuOptAddQuadraticConstraint( + problem, 3, qc_row, qc_col, qc_coeff, 0, NULL, NULL, CUOPT_LESS_THAN, 0.0); + if (status != CUOPT_SUCCESS) { + printf("Error adding quadratic constraint: %d\n", status); + goto DONE; + } + + status = cuOptGetNumConstraints(problem, &num_constraints); + if (status != CUOPT_SUCCESS) { + printf("Error getting num constraints: %d\n", status); + goto DONE; + } + /* 1 linear + 1 quadratic constraint = 2 combined. */ + if (num_constraints != 2) { + printf("Error: expected 2 combined constraints, got %d\n", num_constraints); + status = -1; + goto DONE; + } + + status = cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_LINEAR_CONSTRAINTS, &num_linear); + if (status != CUOPT_SUCCESS) { + printf("Error getting num linear constraints: %d\n", status); + goto DONE; + } + if (num_linear != 1) { + printf("Error: expected 1 linear constraint, got %d\n", num_linear); + status = -1; + goto DONE; + } + + status = + cuOptGetProblemIntAttribute(problem, CUOPT_ATTR_NUM_QUADRATIC_CONSTRAINTS, &num_quadratic); + if (status != CUOPT_SUCCESS) { + printf("Error getting num quadratic constraints: %d\n", status); + goto DONE; + } + if (num_quadratic != 1) { + printf("Error: expected 1 quadratic constraint, got %d\n", num_quadratic); + status = -1; + goto DONE; + } + + if (num_linear + num_quadratic != num_constraints) { + printf("Error: num_linear (%d) + num_quadratic (%d) != num_constraints (%d)\n", + num_linear, + num_quadratic, + num_constraints); + status = -1; + goto DONE; + } + + /* Sized to the documented lengths (num_constraints just queried above, num_variables + * used to build the problem), not a guessed capacity. */ + dual_solution = (cuopt_float_t*)malloc(num_constraints * sizeof(cuopt_float_t)); + reduced_costs = (cuopt_float_t*)malloc(num_variables * sizeof(cuopt_float_t)); + + status = cuOptCreateSolverSettings(&settings); + if (status != CUOPT_SUCCESS) { + printf("Error creating solver settings: %d\n", status); + goto DONE; + } + + status = cuOptSetIntegerParameter(settings, CUOPT_METHOD, CUOPT_METHOD_BARRIER); + if (status != CUOPT_SUCCESS) { + printf("Error setting barrier method: %d\n", status); + goto DONE; + } + + status = cuOptSolve(problem, settings, &solution); + if (status != CUOPT_SUCCESS) { + printf("Error solving QCQP: %d\n", status); + goto DONE; + } + + /* cuOptGetDualSolution on a QCQP solution should return CUOPT_SUCCESS and fill exactly + * the num_constraints-sized buffer with NaN (dual recovery not yet supported). A + * regression back to issue #1751 (writing more than num_constraints entries) would + * overrun this exactly-sized heap allocation. */ + status = cuOptGetDualSolution(solution, dual_solution); + if (status != CUOPT_SUCCESS) { + printf("Error: cuOptGetDualSolution on QCQP should return CUOPT_SUCCESS, got %d\n", status); + status = -1; + goto DONE; + } + for (i = 0; i < num_constraints; ++i) { + if (!isnan(dual_solution[i])) { + printf("Error: dual_solution[%d] expected NaN, got %g\n", i, dual_solution[i]); + status = -1; + goto DONE; + } + } + + /* cuOptGetReducedCosts on a QCQP solution should return CUOPT_SUCCESS and fill exactly + * the num_variables-sized buffer with NaN. */ + status = cuOptGetReducedCosts(solution, reduced_costs); + if (status != CUOPT_SUCCESS) { + printf("Error: cuOptGetReducedCosts on QCQP should return CUOPT_SUCCESS, got %d\n", status); + status = -1; + goto DONE; + } + for (i = 0; i < num_variables; ++i) { + if (!isnan(reduced_costs[i])) { + printf("Error: reduced_costs[%d] expected NaN, got %g\n", i, reduced_costs[i]); + status = -1; + goto DONE; + } + } + + printf("QCQP solution dual methods test passed\n"); + status = CUOPT_SUCCESS; + +DONE: + free(dual_solution); + free(reduced_costs); + cuOptDestroyProblem(&problem); + cuOptDestroySolverSettings(&settings); + cuOptDestroySolution(&solution); + return status; +} + /** * Test CPU-only execution with CUDA_VISIBLE_DEVICES="" and remote execution enabled. * This simulates a CPU host without GPU access. diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp index cab37409fb..a54ad1aec6 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.cpp @@ -483,6 +483,11 @@ TEST(c_api, lp_solution_mip_methods) { EXPECT_EQ(test_lp_solution_mip_methods(), TEST(c_api, mip_solution_lp_methods) { EXPECT_EQ(test_mip_solution_lp_methods(), CUOPT_SUCCESS); } +TEST(c_api, qcqp_solution_dual_methods) +{ + EXPECT_EQ(test_qcqp_solution_dual_methods(), CUOPT_SUCCESS); +} + // ============================================================================= // CPU-Only Execution Tests // These tests verify that cuOpt can run on a CPU-only host with remote execution diff --git a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h index a8c3a1f4e4..a7242d1063 100644 --- a/cpp/tests/linear_programming/c_api_tests/c_api_tests.h +++ b/cpp/tests/linear_programming/c_api_tests/c_api_tests.h @@ -67,6 +67,7 @@ cuopt_int_t test_deterministic_bb(const char* filename, /* Tests for solution interface polymorphism (use inline problems, no file I/O) */ cuopt_int_t test_lp_solution_mip_methods(); cuopt_int_t test_mip_solution_lp_methods(); +cuopt_int_t test_qcqp_solution_dual_methods(); cuopt_int_t test_pdlp_precision_single(const char* filename, cuopt_int_t* termination_status_ptr, diff --git a/cpp/tests/mip/bhw_coeff_reduce_test.cpp b/cpp/tests/mip/bhw_coeff_reduce_test.cpp new file mode 100644 index 0000000000..20b8fdb0f6 --- /dev/null +++ b/cpp/tests/mip/bhw_coeff_reduce_test.cpp @@ -0,0 +1,178 @@ +/* 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 + +namespace cuopt::mathematical_optimization::test { + +using mip::BHW_MAX_LEN; +using mip::bhw_reduce_row; +using mip::bhw_row_rewrite_t; +using mip::bhw_shape_cache_t; + +namespace { + +bhw_row_rewrite_t reduce(const std::vector& coefficients, + double side, + int direction = 1, + bhw_shape_cache_t* cache = nullptr) +{ + return bhw_reduce_row( + coefficients.data(), (int)coefficients.size(), side, direction, cache); +} + +// The whole point of the pass: the rewritten row must accept exactly the same 0/1 points as the +// original. Checked over every point, independently of the extremal-point test the search uses. +bool same_feasible_set(const std::vector& coefficients, + double side, + int direction, + const bhw_row_rewrite_t& rewrite) +{ + constexpr double tol = 1e-9; + const int k = (int)coefficients.size(); + for (uint32_t mask = 0; mask < (1u << k); ++mask) { + double original = 0.0; + int64_t rewritten = 0; + for (int i = 0; i < k; ++i) { + if ((mask >> i & 1u) == 0u) continue; + original += coefficients[i]; + rewritten += rewrite.coefficients[i]; + } + const bool original_ok = direction == 1 ? original <= side + tol : original >= side - tol; + const bool rewritten_ok = + direction == 1 ? rewritten <= rewrite.side : rewritten >= rewrite.side; + if (original_ok != rewritten_ok) return false; + } + return true; +} + +} // namespace + +// Bradley, Hammer and Wolsey (1974) open with this row and reduce it to 4,4,2,2,1,1,1,0 <= 5. That +// rewrite enlarges the LP relaxation (it admits a fractional point of activity 91.75 against a +// right-hand side of 80), so the LP-strength check rejects it and no smaller equivalent form +// survives. +TEST(bhw_coeff_reduce, rejects_a_rewrite_that_weakens_the_relaxation) +{ + EXPECT_FALSE(reduce({65, 64, 41, 22, 13, 12, 8, 2}, 80).accepted); +} + +// The shape that motivated the pass: a 9/10 coefficient against thirds, with a fractional +// right-hand side. Integerizing and reducing lands it in int8 range. +TEST(bhw_coeff_reduce, rational_row_integerizes_and_reduces) +{ + const std::vector row{0.9, 1.0 / 3, 1.0 / 3, 1.0 / 3}; + const auto reduced = reduce(row, 2.0 / 3); + ASSERT_TRUE(reduced.accepted); + EXPECT_EQ(reduced.coefficients, std::vector({3, 1, 1, 1})); + EXPECT_EQ(reduced.side, 2); + EXPECT_TRUE(same_feasible_set(row, 2.0 / 3, 1, reduced)); +} + +// The >= orientation is normalized by negation, so the same row negated must come back negated. +TEST(bhw_coeff_reduce, greater_equal_row_keeps_its_orientation) +{ + const std::vector row{-0.9, -1.0 / 3, -1.0 / 3, -1.0 / 3}; + const auto reduced = reduce(row, -2.0 / 3, -1); + ASSERT_TRUE(reduced.accepted); + EXPECT_EQ(reduced.coefficients, std::vector({-3, -1, -1, -1})); + EXPECT_EQ(reduced.side, -2); + EXPECT_TRUE(same_feasible_set(row, -2.0 / 3, -1, reduced)); +} + +TEST(bhw_coeff_reduce, rejects_rows_with_nothing_to_give_back) +{ + // Already at unit magnitude. + EXPECT_FALSE(reduce({1, 1, 1, 1}, 2).accepted); + // Does not integerize within the rational cap. + EXPECT_FALSE(reduce({M_PI, 1, 1}, 2).accepted); + // Outside the enumerable width. + EXPECT_FALSE(reduce({5}, 2).accepted); + EXPECT_FALSE(reduce(std::vector(BHW_MAX_LEN + 1, 3.0), 5).accepted); + // Every point feasible, so there is nothing to separate. + EXPECT_FALSE(reduce({3, 2, 2}, 100).accepted); + // No point feasible. + EXPECT_FALSE(reduce({3, 2, 2}, -1).accepted); +} + +TEST(bhw_coeff_reduce, rejects_rows_with_a_zero_coefficient) +{ + EXPECT_FALSE(reduce({65, 64, 41, 22, 13, 12, 8, 2, 0}, 80).accepted); + EXPECT_FALSE(reduce({0, 9, 7, 6, 6, 4}, 20).accepted); + EXPECT_FALSE(reduce({6, 0}, 5).accepted); +} + +TEST(bhw_coeff_reduce, memoized_result_matches_the_uncached_one) +{ + bhw_shape_cache_t cache; + const std::vector> rows{ + {0.9, 1.0 / 3, 1.0 / 3, 1.0 / 3}, {6, 4, 3, 2}, {-6, 4, 3, -2}, {9, 7, 6, 6, 4}}; + for (const auto& row : rows) { + for (int repeat = 0; repeat < 2; ++repeat) { + const auto cached = reduce(row, 12, 1, &cache); + const auto uncached = reduce(row, 12, 1, nullptr); + EXPECT_EQ(cached.accepted, uncached.accepted); + EXPECT_EQ(cached.coefficients, uncached.coefficients); + EXPECT_EQ(cached.side, uncached.side); + } + } +} + +// The invariant that matters, over mixed signs, both orientations and rational coefficients: an +// accepted rewrite never changes which 0/1 points satisfy the row. +TEST(bhw_coeff_reduce, accepted_rewrites_preserve_the_feasible_set) +{ + std::mt19937_64 rng(20260805); + bhw_shape_cache_t cache; + const int denominators[] = {1, 2, 3, 4, 5, 6, 8, 10, 12, 16}; + int accepted = 0; + + for (int trial = 0; trial < 20000; ++trial) { + const int len = 2 + (int)(rng() % 7); + const int direction = (rng() & 1u) != 0u ? 1 : -1; + const int denominator = denominators[rng() % 10]; + + std::vector row(len); + double positive_sum = 0.0; + double negative_sum = 0.0; + for (int i = 0; i < len; ++i) { + const int64_t numerator = 1 + (int64_t)(rng() % 30); + row[i] = (double)numerator / denominator * ((rng() & 3u) == 0u ? -1.0 : 1.0); + if (row[i] > 0.0) + positive_sum += row[i]; + else + negative_sum += row[i]; + } + // Put the side inside the activity range so the row is not trivially satisfied or violated. + double side = negative_sum + (positive_sum - negative_sum) * (double)(rng() % 1001) / 1000.0; + side = std::round(side * denominator) / denominator; + if (direction == -1) { + for (double& value : row) + value = -value; + side = -side; + } + + const auto reduced = reduce(row, side, direction, &cache); + if (!reduced.accepted) continue; + ++accepted; + + ASSERT_EQ((int)reduced.coefficients.size(), len); + ASSERT_TRUE(same_feasible_set(row, side, direction, reduced)) + << "rewrite changed the 0/1 feasible set on trial " << trial; + } + // Guards against the generator drifting into a corner where nothing is ever reduced. + EXPECT_GT(accepted, 1000); +} + +} // namespace cuopt::mathematical_optimization::test diff --git a/cpp/tests/mip/block_bve_test.cu b/cpp/tests/mip/block_bve_test.cu new file mode 100644 index 0000000000..c1aca2f690 --- /dev/null +++ b/cpp/tests/mip/block_bve_test.cu @@ -0,0 +1,936 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "../linear_programming/utilities/pdlp_test_utilities.cuh" // gtest + make_path_absolute (mip_utils.cuh deps) +#include "mip_utils.cuh" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// ---- host enumeration projection (the differential oracle) ---- + +template +inline bool bve_is_finite(f_t x) +{ + // finite iff it equals itself (rules out NaN) and is strictly within +/- inf + return (x == x) && (x < INFINITY) && (x > -INFINITY); +} + +// Feasibility of one packed row under a full local assignment `val` (length na+nb), with tolerance. +template +inline bool bve_row_sat(const bve_block_t& blk, int r, const int* val, f_t tol) +{ + f_t s = 0; + for (int k = blk.row_off[r]; k < blk.row_off[r + 1]; ++k) { + s += blk.row_coef[k] * (f_t)val[blk.row_var[k]]; + } + if (bve_is_finite(blk.row_up[r]) && s > blk.row_up[r] + tol) return false; + if (bve_is_finite(blk.row_lo[r]) && s < blk.row_lo[r] - tol) return false; + return true; +} + +// Project the block onto its boundary. `feas[m]` (length 2^nb) is set to 1 iff boundary pattern m +// (nb bits) admits SOME interior assignment satisfying every block row, and `witness[m]` receives +// the packed interior assignment (na bits) of the FIRST feasible completion. Both are left 0 for +// infeasible patterns. The GPU kernel must match this exactly. +template +inline void bve_project(const bve_block_t& blk, f_t tol, uint8_t* feas, uint32_t* witness) +{ + const int na = blk.na, nb = blk.nb; + int val[BVE_MAX_SCOPE]; + for (uint32_t m = 0; m < (1u << nb); ++m) { + for (int j = 0; j < nb; ++j) + val[na + j] = (m >> j) & 1u; + feas[m] = 0; + witness[m] = 0u; + for (uint32_t am = 0; am < (1u << na); ++am) { + for (int j = 0; j < na; ++j) + val[j] = (am >> j) & 1u; + bool ok = true; + for (int r = 0; r < blk.n_rows && ok; ++r) + ok = bve_row_sat(blk, r, val, tol); + if (ok) { + feas[m] = 1; + witness[m] = am; + break; + } + } + } +} + +enum class bve_status_t : int { + kReduced = 0, // sanity check passed; `clauses` is a sound replacement for the block rows + kSkipCaps = 1, // block violates a bound cap (defensive; detector should pre-filter) + kSkipGrowth = 2, // |clauses| > |rows| + margin (would grow the row count) + kSkipCheckFailed = + 3 // clauses did not reproduce feas (sanity check failed) => keep block verbatim +}; + +// Full per-block core on the host: project -> prime-implicate CNF -> growth gate -> inline sanity +// check. The production commit_projected does the same, but reads feas/witness from the GPU instead +// of the host bve_project above. +template +inline bve_status_t bve_project_and_check(const bve_block_t& blk, + f_t tol, + i_t margin, + bve_clause_t* clauses, + i_t* n_clauses, + uint32_t* witness) +{ + *n_clauses = 0; + if (blk.nb <= 0 || blk.nb > BVE_MAX_BOUNDARY) return bve_status_t::kSkipCaps; + if (blk.na < 0 || blk.na + blk.nb > BVE_MAX_SCOPE) return bve_status_t::kSkipCaps; + if (blk.n_rows < 0 || blk.n_rows > BVE_MAX_ROWS) return bve_status_t::kSkipCaps; + + uint8_t feas[BVE_MAX_PATTERNS]; + bve_project(blk, tol, feas, witness); + bve_cover_scratch_t scratch; + const int nc = bve_greedy_prime_cover(feas, blk.nb, clauses, BVE_MAX_CLAUSES, scratch); + if (nc < 0) return bve_status_t::kSkipGrowth; // clause explosion past cap + if (nc > blk.n_rows + margin) return bve_status_t::kSkipGrowth; + if (!bve_sanity_check(feas, blk.nb, clauses, nc)) return bve_status_t::kSkipCheckFailed; + *n_clauses = nc; + return bve_status_t::kReduced; +} + +} // namespace cuopt::mathematical_optimization::mip + +namespace cuopt::mathematical_optimization::test { + +namespace mip = cuopt::mathematical_optimization::mip; + +// A minimal "a = b OR c, with b+c <= 1 forced" block. `a` is the only zero-objective binary aux +// (b and c carry objective, so they stay on the boundary and are never absorbed into the interior). +// Eliminating `a` by exact projection leaves exactly ONE prime-implicate clause: b + c <= 1 (the +// boundary pattern b=c=1 is infeasible because it would force a=1 and violate a+b+c<=2). +static constexpr const char* kBlockLp = R"LP( +Minimize + obj: b + c +Subject To + r0: a - b >= 0 + r1: a - c >= 0 + r2: a + b + c <= 2 +Binaries + a + b + c +End +)LP"; + +// Same gadget with every row scaled by 1/2, so the block coefficients and bounds are FRACTIONAL. +// The feasible region (hence the reduction: b + c <= 1, `a` eliminated) is identical, as positive +// row scaling preserves feasibility. This forces block-BVE's per-row integerization +// (row_int_scale) to recover integer coefficients before the exact tol-0 projection; a wrong +// integerization breaks either the reduction or its reconstruction. +static constexpr const char* kFractionalBlockLp = R"LP( +Minimize + obj: b + c +Subject To + r0: 0.5 a - 0.5 b >= 0 + r1: 0.5 a - 0.5 c >= 0 + r2: 0.5 a + 0.5 b + 0.5 c <= 1 +Binaries + a + b + c +End +)LP"; + +// solve_mip opens an OMP team before MIP internals that use taskloops; probing_cache sizes its +// pool from omp_get_num_threads()-1 (0 outside a parallel region → silent no-op). +template +static void with_mip_omp_team(F&& f) +{ + const int num_threads = std::max(2, omp_get_max_threads()); + const int saved_max_active_levels = omp_get_max_active_levels(); + if (saved_max_active_levels < 2) { omp_set_max_active_levels(2); } +#pragma omp parallel num_threads(num_threads) + { +#pragma omp masked + { + f(); + } + } + if (saved_max_active_levels < 2) { omp_set_max_active_levels(saved_max_active_levels); } +} + +// Production implication adjacency: bounds → probing cache → trivial compact → bve_build_impl_adj. +// If `out_infeasible` is non-null, probing infeasibility is reported there (empty adj returned); +// otherwise the caller is assumed to expect a feasible instance and we ASSERT that. +static std::vector> probing_impl_adj(mip::problem_t& problem, + bool* out_infeasible = nullptr) +{ + mip_solver_settings_t settings{}; + cuopt::timer_t timer(30.0); + mip::mip_solver_t solver(problem, settings, timer); + problem.tolerances = settings.get_tolerances(); + mip::bound_presolve_t bound_presolve(solver.context); + + bool infeasible = false; + with_mip_omp_team([&]() { + auto term_crit = bound_presolve.solve(problem); + if (term_crit != mip::termination_criterion_t::NO_UPDATE) { + bound_presolve.set_updated_bounds(problem); + } + cuopt::timer_t probing_timer(30.0); + infeasible = mip::compute_probing_cache(bound_presolve, problem, probing_timer); + if (!infeasible) { + constexpr bool remap_cache_ids = true; + mip::trivial_presolve(problem, remap_cache_ids); + } + }); + if (out_infeasible != nullptr) { + *out_infeasible = infeasible; + } else { + EXPECT_FALSE(infeasible); + } + if (infeasible) { return {}; } + return mip::bve_build_impl_adj( + bound_presolve.probing_cache, problem.reverse_original_ids, problem.n_variables, timer); +} + +// Build one block by hand for the projection-core tests. Local ids: a=0 (interior), b=1, c=2. +static mip::bve_block_t make_block() +{ + const double INF = std::numeric_limits::infinity(); + mip::bve_block_t blk{}; + blk.na = 1; + blk.nb = 2; + blk.n_rows = 3; + int nz = 0; + auto row = [&](int r, std::initializer_list> terms, double lo, double up) { + blk.row_off[r] = nz; + for (const auto& t : terms) { + blk.row_var[nz] = t.first; + blk.row_coef[nz] = t.second; + ++nz; + } + blk.row_lo[r] = lo; + blk.row_up[r] = up; + }; + row(0, {{0, 1.0}, {1, -1.0}}, 0.0, INF); // a - b >= 0 + row(1, {{0, 1.0}, {2, -1.0}}, 0.0, INF); // a - c >= 0 + row(2, {{0, 1.0}, {1, 1.0}, {2, 1.0}}, -INF, 2.0); // a + b + c <= 2 + blk.row_off[blk.n_rows] = nz; + return blk; +} + +// --- 1. projection core: the block sanity checks, yields one clause and the right witness --- +TEST(block_bve_core, reduces_block_and_sanity_checks) +{ + auto blk = make_block(); + mip::bve_clause_t clauses[mip::BVE_MAX_CLAUSES]; + uint32_t witness[mip::BVE_MAX_PATTERNS]; + int n_clauses = 0; + auto st = mip::bve_project_and_check(blk, 1e-6, /*margin=*/0, clauses, &n_clauses, witness); + + EXPECT_EQ(st, mip::bve_status_t::kReduced); + ASSERT_EQ(n_clauses, 1); + // clause forbids boundary pattern b=1,c=1 (bits 0 and 1 both set): b + c <= 1 + EXPECT_EQ(clauses[0].lit_mask, 3u); + EXPECT_EQ(clauses[0].bit_mask, 3u); + // witness: (b=0,c=0)->a=0, (b=1,c=0)->a=1, (b=0,c=1)->a=1 + EXPECT_EQ(witness[0], 0u); + EXPECT_EQ(witness[1], 1u); + EXPECT_EQ(witness[2], 1u); +} + +// --- 2. sanity check safety: the INDEPENDENT clause evaluator rejects any clause set that +// misrepresents +// feas (the certifying-algorithm result check; not a machine-checkable certificate) --- +TEST(block_bve_core, sanity_check_rejects_corrupted_clauses) +{ + // feasible-pattern array for the block above (b=c=1 is the only infeasible pattern) + const uint8_t feas[4] = {1, 1, 1, 0}; + const mip::bve_clause_t correct[1] = {{3u, 3u}}; // b + c <= 1 + EXPECT_TRUE(mip::bve_sanity_check(feas, 2, correct, 1)); + + // dropping the clause entirely: the CNF would accept b=c=1, but feas forbids it -> rejected + EXPECT_FALSE(mip::bve_sanity_check(feas, 2, correct, 0)); + // a wrong clause (forbid b=1 only) makes a genuinely feasible pattern look infeasible -> rejected + const mip::bve_clause_t wrong[1] = {{1u, 1u}}; + EXPECT_FALSE(mip::bve_sanity_check(feas, 2, wrong, 1)); +} + +// --- the row integerization GATE. block-BVE scales each block row to integers via +// find_scaling_rational (strict caps mirroring row_int_scale) so the projection is exact at +// tolerance 0; a row that will not integerize within the caps must be REJECTED (NaN), never rounded +// into a different model. This pins the accept/reject decision that keeps large / non-rational +// coefficients off the exact-projection path. --- +TEST(block_bve_core, integer_scaling_accepts_rational_rejects_pathological) +{ + // Strict caps matching row_int_scale (maxdnom/maxfinal = BVE_INT_SCALE_MAX = 1e6). + const double kMaxScale = 1e12; + const int64_t kMaxDenom = 1000000; + const double kMaxFinal = 1e6; + const double kIntTol = 1e-9; + auto all_integer = [](double s, const std::vector& v) { + for (double c : v) + if (std::abs(s * c - std::round(s * c)) >= 1e-9) return false; + return true; + }; + + // Fractional-but-rational: {1/2, 1/4, -3/4, 1} integerize (expected multiplier 4). + { + std::vector v{0.5, 0.25, -0.75, 1.0}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + ASSERT_TRUE(std::isfinite(s)) << "rational coefficients must integerize"; + EXPECT_GT(s, 0.0); + EXPECT_TRUE(all_integer(s, v)); + } + + // Large integer coefficients stay exact (already integer -> multiplier 1, no rounding). + { + std::vector v{1e9, -1e9, 3.0}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + ASSERT_TRUE(std::isfinite(s)); + EXPECT_TRUE(all_integer(s, v)); + } + + // Pathological: distinct prime reciprocals need lcm(11,13,17,19,23) = 1062347 > maxfinal (1e6), + // so no bounded integer multiplier exists -> rejected (NaN), NOT silently rounded. + { + std::vector v{1.0 / 11, 1.0 / 13, 1.0 / 17, 1.0 / 19, 1.0 / 23}; + double s = cuopt::find_scaling_rational(v, kMaxScale, kMaxDenom, kMaxFinal, kIntTol); + EXPECT_TRUE(std::isnan(s)) << "un-integerizable coefficients must be rejected, got " << s; + } +} + +// A cached probe and a block projection are both valid, so they can only disagree when the +// antecedent they share is unsatisfiable. That fixes the variable to the opposite value; the model +// is infeasible only once both polarities are contradicted, which apply_bve_fixings derives from +// two fixings that disagree. An empty intersection on its own must not be reported as global +// infeasibility. +TEST(block_bve_core, cache_contradiction_fixes_the_variable_instead_of_failing) +{ + constexpr int var = 7; + constexpr int forced = 9; + + // Probing has x7 = 0 => x9 = 0; the exact projection has x7 = 0 => x9 = 1. Slot 1 is left + // unpopulated, which also exercises the empty-bound-map guard. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 0.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}}, fixings); + + ASSERT_EQ(fixings.size(), 1u) << "a contradicted probe yields one fixing, not infeasibility"; + EXPECT_EQ(fixings[0].first, var); + EXPECT_TRUE(fixings[0].second) << "x7 = 0 is disproved, so x7 = 1"; + } + + // Both polarities contradicted: the two disagreeing fixings are what proves infeasibility. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 0.0}; + entries[1].val_interval = {1.0, mip::interval_type_t::EQUALS}; + entries[1].var_to_cached_bound_map[forced] = {0.0, 0.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}, {var, forced, true, true}}, fixings); + + ASSERT_EQ(fixings.size(), 2u); + std::sort(fixings.begin(), fixings.end()); + EXPECT_EQ(fixings[0], std::make_pair(var, false)); + EXPECT_EQ(fixings[1], std::make_pair(var, true)); + } + + // A forcing consistent with the cached interval tightens it and fixes nothing. + { + mip::probing_cache_t cache; + std::array, 2> entries{}; + entries[0].val_interval = {0.0, mip::interval_type_t::EQUALS}; + entries[0].var_to_cached_bound_map[forced] = {0.0, 1.0}; + cache.probing_cache.insert({var, entries}); + + std::vector> fixings; + cache.merge_forcings({{var, forced, false, true}}, fixings); + + EXPECT_TRUE(fixings.empty()) << "a consistent forcing must not fix anything"; + const auto& bound = cache.probing_cache.at(var)[0].var_to_cached_bound_map.at(forced); + EXPECT_EQ(bound.lb, 1.0); + EXPECT_EQ(bound.ub, 1.0); + } +} + +// Build a random block LAYOUT (na/nb/n_rows + sparsity pattern), coefficients/bounds left unset. +// Reps of one shape reuse the SAME layout so they land in one GPU shape-bin (exercising the num>1 +// path). +static mip::bve_block_t make_block_layout(std::mt19937& rng, int na, int nb, int n_rows) +{ + const int scope = na + nb; + mip::bve_block_t blk{}; + blk.na = na; + blk.nb = nb; + blk.n_rows = n_rows; + std::uniform_int_distribution present(0, 1); // is a var in this row + int nz = 0; + for (int r = 0; r < n_rows; ++r) { + blk.row_off[r] = nz; + for (int v = 0; v < scope; ++v) + if (present(rng)) blk.row_var[nz++] = v; + if (nz == blk.row_off[r]) blk.row_var[nz++] = r % scope; // never leave an empty row + } + blk.row_off[n_rows] = nz; + return blk; +} + +// Fill a layout's coefficients (small integers) and bounds (randomly ±inf), leaving the pattern +// fixed. +static void randomize_block_data(std::mt19937& rng, mip::bve_block_t& blk) +{ + const double INF = std::numeric_limits::infinity(); + const double coefs[4] = {-2.0, -1.0, 1.0, 2.0}; + std::uniform_int_distribution coef_pick(0, 3); + std::uniform_int_distribution bnd_pick(0, 2); // 0:[lo,inf] 1:[-inf,up] 2:[lo,up] + for (int k = 0; k < blk.row_off[blk.n_rows]; ++k) + blk.row_coef[k] = coefs[coef_pick(rng)]; + for (int r = 0; r < blk.n_rows; ++r) { + const int terms = blk.row_off[r + 1] - blk.row_off[r]; + // Activity under 0/1 vars and coefs in {-2,-1,1,2} lies in [-2*terms, 2*terms]. Pick finite + // uppers in [0, 2*terms] so they can bind (not always equal to the loose max activity). + const double lo = -terms; + std::uniform_int_distribution up_pick(0, 2 * terms); + const double up = up_pick(rng); + const int kind = bnd_pick(rng); + blk.row_lo[r] = (kind == 1) ? -INF : lo; + blk.row_up[r] = (kind == 0) ? INF : up; + } +} + +// --- projection correctness: the GPU batch projection must equal the host enumeration oracle on a +// diverse batch (varied na/nb/rows, ±inf bounds, multiple distinct shapes, and >1-block bins). +// This is what pins projection correctness; the inline sanity check cannot (it trusts feas). +// Runs the same function two independent ways and asserts feas + witness agree everywhere. +TEST(block_bve_projection, gpu_batch_matches_host_oracle) +{ + const raft::handle_t handle_{}; + std::mt19937 rng(12345u); + + // several shapes, several blocks each; reps share a layout -> one shape-bin with num>1 + const int shapes[][3] = {{1, 2, 3}, {2, 2, 2}, {1, 3, 4}, {3, 3, 5}, {2, 4, 3}, {4, 2, 4}}; + std::vector> blocks; + for (const auto& s : shapes) { + const mip::bve_block_t layout = make_block_layout(rng, s[0], s[1], s[2]); + for (int rep = 0; rep < 6; ++rep) { + mip::bve_block_t blk = layout; + randomize_block_data(rng, blk); + blocks.push_back(blk); + } + } + + std::vector> cands(blocks.size()); + for (size_t i = 0; i < blocks.size(); ++i) + cands[i].blk = + blocks[i]; // the service reads only .blk; interior/boundary/rows are unused here + + cuopt::timer_t no_deadline(std::numeric_limits::infinity()); + mip::bve_project_batch_gpu(handle_, cands, 1e-6, no_deadline); + + for (size_t i = 0; i < blocks.size(); ++i) { + uint8_t exp_feas[mip::BVE_MAX_PATTERNS]; + uint32_t exp_wit[mip::BVE_MAX_PATTERNS]; + mip::bve_project(blocks[i], 1e-6, exp_feas, exp_wit); + const int patterns = 1 << blocks[i].nb; + for (int m = 0; m < patterns; ++m) { + EXPECT_EQ(cands[i].projection.feasible[m], exp_feas[m]) << "block " << i << " pattern " << m; + if (exp_feas[m]) // witness only defined for feasible patterns + EXPECT_EQ(cands[i].projection.witness[m], exp_wit[m]) << "block " << i << " pattern " << m; + } + } +} + +// Fill a layout with LARGE integer coefficients and integer bounds (all exact fp64 integers, well +// under 2^53), leaving the pattern fixed. This is the shape block-BVE feeds the projection after +// integerization, and the magnitude range where a 1e-6-tolerance fp test would be marginal but +// exact integer arithmetic is not. +static void randomize_block_data_integer(std::mt19937& rng, mip::bve_block_t& blk) +{ + const double INF = std::numeric_limits::infinity(); + const double coefs[] = {-2e6, -1e6, 1e6, 2e6, 5e6}; + std::uniform_int_distribution coef_pick(0, 4); + std::uniform_int_distribution bnd_pick(0, 2); // 0:[lo,inf] 1:[-inf,up] 2:[lo,up] + for (int k = 0; k < blk.row_off[blk.n_rows]; ++k) + blk.row_coef[k] = coefs[coef_pick(rng)]; + for (int r = 0; r < blk.n_rows; ++r) { + const int terms = blk.row_off[r + 1] - blk.row_off[r]; + // Activity lies in [-5e6*terms, 5e6*terms]; pick finite integer bounds (multiples of 1e6) that + // can bind. + const double lo = -5e6 * terms; + std::uniform_int_distribution up_pick(0, 2 * terms); + const double up = 1e6 * up_pick(rng); + const int kind = bnd_pick(rng); + blk.row_lo[r] = (kind == 1) ? -INF : lo; + blk.row_up[r] = (kind == 0) ? INF : up; + } +} + +// --- the EXACT projection path. Production integerizes each block and projects at tolerance 0; +// the 1e-6 differential test above never exercises that. On large-integer-coefficient blocks +// the GPU projection at tol 0 must still equal the host enumeration oracle at tol 0 everywhere. +// --- +TEST(block_bve_projection, exact_projection_matches_host_at_tol0) +{ + const raft::handle_t handle_{}; + std::mt19937 rng(2024u); + + const int shapes[][3] = {{1, 2, 3}, {2, 2, 2}, {1, 3, 4}, {3, 3, 5}, {2, 4, 3}}; + std::vector> blocks; + for (const auto& s : shapes) { + const mip::bve_block_t layout = make_block_layout(rng, s[0], s[1], s[2]); + for (int rep = 0; rep < 6; ++rep) { + mip::bve_block_t blk = layout; + randomize_block_data_integer(rng, blk); + blocks.push_back(blk); + } + } + + std::vector> cands(blocks.size()); + for (size_t i = 0; i < blocks.size(); ++i) + cands[i].blk = blocks[i]; + + cuopt::timer_t no_deadline(std::numeric_limits::infinity()); + mip::bve_project_batch_gpu(handle_, cands, 0.0, no_deadline); // exact: tol 0 + + for (size_t i = 0; i < blocks.size(); ++i) { + uint8_t exp_feas[mip::BVE_MAX_PATTERNS]; + uint32_t exp_wit[mip::BVE_MAX_PATTERNS]; + mip::bve_project(blocks[i], 0.0, exp_feas, exp_wit); + const int patterns = 1 << blocks[i].nb; + for (int m = 0; m < patterns; ++m) { + EXPECT_EQ(cands[i].projection.feasible[m], exp_feas[m]) << "block " << i << " pattern " << m; + if (exp_feas[m]) + EXPECT_EQ(cands[i].projection.witness[m], exp_wit[m]) << "block " << i << " pattern " << m; + } + } +} + +// --- 3. end-to-end: run the pass on a problem_t, then reconstruct through postsolve --- +TEST(block_bve_presolve, end_to_end_reduction_and_reconstruction) +{ + const raft::handle_t handle_{}; + auto model = io::read_lp_from_string(kBlockLp); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + auto impl_adj = probing_impl_adj(problem); + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + EXPECT_TRUE(applied); + EXPECT_EQ(problem.n_variables, n_before - 1); // exactly `a` eliminated + + // Set a reduced solution with the first surviving (boundary) variable = 1; whichever of b/c it + // is, the block forces a = 1, so a correct reconstruction must satisfy the ORIGINAL constraints. + std::vector reduced(problem.n_variables, 0.0); + if (!reduced.empty()) reduced[0] = 1.0; + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), reduced.data(), reduced.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + ASSERT_EQ(full.size(), static_cast(n_before)); // expanded back to all three variables + // The reconstructed full assignment must satisfy EVERY original constraint. This is order- + // independent (no assumption about which index is a/b/c): if the eliminated aux is reconstructed + // wrongly, a - b >= 0 or a - c >= 0 is violated. Since one boundary variable is set to 1, a + // correct reconstruction forces the aux to 1, so the feasibility check below is exactly that + // correctness test. + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } +} + +// --- end-to-end with fractional block coefficients (the same gadget, rows scaled by 1/2). The +// reduction and its reconstruction must be identical to the integer gadget: block-BVE has to +// integerize the 0.5 coefficients before the exact projection and undo it correctly at +// postsolve. +TEST(block_bve_presolve, fractional_gadget_reduces_and_reconstructs) +{ + const raft::handle_t handle_{}; + auto model = io::read_lp_from_string(kFractionalBlockLp); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + auto impl_adj = probing_impl_adj(problem); + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + // Probing/trivial may already have eliminated the aux; either way exactly one variable is gone. + EXPECT_TRUE(applied || problem.n_variables < n_before); + ASSERT_EQ(problem.n_variables, n_before - 1) << "fractional gadget did not eliminate the aux"; + + // Set the first surviving (boundary) variable to 1; a correct reconstruction forces the aux so + // the full assignment satisfies every ORIGINAL (fractional) constraint. + std::vector reduced(problem.n_variables, 0.0); + if (!reduced.empty()) reduced[0] = 1.0; + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), reduced.data(), reduced.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + ASSERT_EQ(full.size(), static_cast(n_before)); + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } +} + +// Brute-force the (small, binary) reduced problem_t: enumerate all 2^n assignments, return whether +// any is feasible, the min solver-space objective, and its argmin. +struct bve_bf_t { + bool found; + double solver_obj; + std::vector x; +}; +static bve_bf_t brute_force_binary(mip::problem_t& problem) +{ + auto stream = problem.handle_ptr->get_stream(); + auto h_off = cuopt::host_copy(problem.offsets, stream); + auto h_var = cuopt::host_copy(problem.variables, stream); + auto h_coef = cuopt::host_copy(problem.coefficients, stream); + auto h_clb = cuopt::host_copy(problem.constraint_lower_bounds, stream); + auto h_cub = cuopt::host_copy(problem.constraint_upper_bounds, stream); + auto h_obj = cuopt::host_copy(problem.objective_coefficients, stream); + auto h_vb = cuopt::host_copy(problem.variable_bounds, stream); + problem.handle_ptr->sync_stream(); + + const int nv = problem.n_variables; + const int nr = problem.n_constraints; + for (int v = 0; v < nv; ++v) { // corpus is pure 0-1 + EXPECT_NEAR(get_lower(h_vb[v]), 0.0, 1e-9); + EXPECT_NEAR(get_upper(h_vb[v]), 1.0, 1e-9); + } + + bve_bf_t r{false, 0.0, {}}; + const double eps = 1e-6; + const uint64_t total = (nv >= 63) ? 0 : (uint64_t{1} << nv); + std::vector x(nv); + for (uint64_t mask = 0; mask < total; ++mask) { + for (int v = 0; v < nv; ++v) + x[v] = (mask >> v) & 1u; + bool ok = true; + for (int rr = 0; rr < nr && ok; ++rr) { + double s = 0.0; + for (int k = h_off[rr]; k < h_off[rr + 1]; ++k) + s += h_coef[k] * x[h_var[k]]; + if (s < h_clb[rr] - eps || s > h_cub[rr] + eps) ok = false; + } + if (!ok) continue; + double obj = 0.0; + for (int v = 0; v < nv; ++v) + obj += h_obj[v] * x[v]; + if (!r.found || obj < r.solver_obj - eps) { + r.found = true; + r.solver_obj = obj; + r.x = x; + } + } + return r; +} + +TEST(block_bve_regression, all_feasible_projection_can_remove_the_last_rows) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps( + make_path_absolute("mip/block_bve/all_feasible_projection.mps"), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + + bool probing_infeasible = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeasible); + ASSERT_FALSE(probing_infeasible) << "probing must leave the all-feasible projection for BVE"; + ASSERT_TRUE( + std::any_of(impl_adj.begin(), impl_adj.end(), [](const auto& adj) { return !adj.empty(); })) + << "fixture must provide an implication edge for the zero-objective auxiliary"; + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + bool applied = false; + ASSERT_NO_THROW(applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units)); + ASSERT_TRUE(applied) << "the all-feasible projection should eliminate its auxiliary"; + + ASSERT_LE(problem.n_variables, 24); + const auto bf = brute_force_binary(problem); + ASSERT_TRUE(bf.found) << "eliminating a tautological projection changed feasibility"; + EXPECT_NEAR(bf.solver_obj, 0.0, 1e-6); +} + +TEST(block_bve_regression, all_infeasible_projection_remains_infeasible) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps( + make_path_absolute("mip/block_bve/all_infeasible_projection.mps"), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + + bool probing_infeasible = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeasible); + ASSERT_FALSE(probing_infeasible) + << "the fixture must reach BVE instead of being discharged by probing"; + ASSERT_TRUE( + std::any_of(impl_adj.begin(), impl_adj.end(), [](const auto& adj) { return !adj.empty(); })) + << "fixture must provide an implication edge for the zero-objective auxiliary"; + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + ASSERT_NO_THROW((void)mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units)); + + ASSERT_LE(problem.n_variables, 24); + const auto bf = brute_force_binary(problem); + EXPECT_FALSE(bf.found) << "BVE lost the empty clause produced by the projection"; +} + +// Corpus of small 0-1 instances whose optima were cross-checked OFFLINE by brute force AND HiGHS. +// MPS live in datasets/mip/block_bve/; optima inlined here. Mix: gadget-rich (block-BVE fires), +// no-op/soundness (aux-with-objective, random feasible ILPs), and infeasible. +struct bve_case_t { + const char* file; + bool feasible; + double optimum; + bool expect_reduce; // gadget should shrink via probing and/or block-BVE +}; +static const bve_case_t kBveCases[] = { + {"mip/block_bve/or_used.mps", true, 1.0, true}, + {"mip/block_bve/and_used.mps", true, -2.0, true}, + {"mip/block_bve/neq_used.mps", true, -3.0, true}, + {"mip/block_bve/chain_or.mps", true, 1.0, true}, + {"mip/block_bve/two_gadgets.mps", true, 2.0, true}, + {"mip/block_bve/heavy_reduce.mps", true, 2.0, true}, + {"mip/block_bve/aux_with_obj.mps", true, 4.0, false}, + {"mip/block_bve/mixed.mps", true, -1.0, false}, + {"mip/block_bve/infeasible.mps", false, 0.0, false}, + {"mip/block_bve/random_a.mps", true, -3.0, false}, + {"mip/block_bve/random_b.mps", true, -5.0, false}, + {"mip/block_bve/random_c.mps", true, -1.0, false}, +}; + +// End-to-end equivalence: for each corpus instance, run the pass, brute-force the reduced model, +// and assert block-BVE preserved the answer. block-BVE is a PRIMAL, optimum-preserving reduction, +// so the bar is: reduced optimum == known optimum, the reduced optimum reconstructs to an +// ORIGINAL-feasible point with that objective, and infeasibility is preserved. This stresses the +// full detect -> project +// -> commit -> install -> reconstruct chain (incl. variable_mapping + witness replay), which the +// component tests above don't. +TEST(block_bve_equivalence, preserves_optimum_and_reconstruction_on_corpus) +{ + const raft::handle_t handle_{}; + bool any_reduced = false; + for (const auto& c : kBveCases) { + SCOPED_TRACE(c.file); + auto model = io::read_mps(make_path_absolute(c.file), /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + mip::problem_t problem(op_problem); + problem.preprocess_problem(); + problem.presolve_data.initialize_var_mapping(problem, problem.handle_ptr); + const int n_before = problem.n_variables; + + bool probing_infeas = false; + auto impl_adj = probing_impl_adj(problem, &probing_infeas); + if (probing_infeas) { + EXPECT_FALSE(c.feasible) << "probing proved infeasible on a feasible instance"; + continue; + } + + cuopt::timer_t bve_timer(10.0); + double bve_work_units = 0.0; + const bool applied = mip::block_bve_presolve(problem, impl_adj, bve_timer, bve_work_units); + // Probing/trivial may already have eliminated the aux; BVE then correctly no-ops. + if (applied || problem.n_variables < n_before) { any_reduced = true; } + if (applied) { + EXPECT_LT(problem.n_variables, n_before) << "applied but variable count unchanged"; + } + if (c.expect_reduce) { + EXPECT_LT(problem.n_variables, n_before) + << "gadget fixture expected a reduction via probing and/or block-BVE"; + } + + ASSERT_LE(problem.n_variables, 24) << "brute force enumerates 2^n; keep the corpus small"; + auto bf = brute_force_binary(problem); + if (!c.feasible) { + // NOTE: if preprocess detects the infeasibility upstream and collapses the model, this may + // need to become a problem-status check instead of a no-feasible-point check. + EXPECT_FALSE(bf.found) << "reduced model is feasible but the instance is infeasible"; + continue; + } + ASSERT_TRUE(bf.found) << "reduced model is infeasible but the instance is feasible"; + + // The reduced optimum must reconstruct to an ORIGINAL-feasible point whose ORIGINAL objective + // equals the known optimum. This is offset/scaling-independent (evaluated directly on the + // original model) and catches both directions: a cut optimum -> recon_obj > optimum; a spurious + // better solution -> either the reconstruction is original-infeasible or recon_obj < optimum. + rmm::device_uvector assignment(problem.n_variables, handle_.get_stream()); + raft::copy(assignment.data(), bf.x.data(), bf.x.size(), handle_.get_stream()); + problem.presolve_data.post_process_assignment(problem, assignment, /*resize_to_original=*/true); + auto full = cuopt::host_copy(assignment, handle_.get_stream()); + handle_.sync_stream(); + + auto m_off = model.get_constraint_matrix_offsets(); + auto m_var = model.get_constraint_matrix_indices(); + auto m_val = model.get_constraint_matrix_values(); + auto m_rl = model.get_constraint_lower_bounds(); + auto m_ru = model.get_constraint_upper_bounds(); + for (size_t r = 0; r + 1 < m_off.size(); ++r) { + double s = 0.0; + for (int k = m_off[r]; k < m_off[r + 1]; ++k) + s += m_val[k] * full[m_var[k]]; + EXPECT_GE(s, m_rl[r] - 1e-6); + EXPECT_LE(s, m_ru[r] + 1e-6); + } + auto m_obj = model.get_objective_coefficients(); + ASSERT_EQ(full.size(), m_obj.size()) << "reconstruction is not in the original column frame"; + double recon_obj = 0.0; + for (size_t j = 0; j < m_obj.size(); ++j) + recon_obj += m_obj[j] * full[j]; + EXPECT_NEAR(recon_obj, c.optimum, 1e-6); + } + EXPECT_TRUE(any_reduced) << "corpus exercised no probing/block-BVE reduction path"; +} + +// Drive production MIP presolve (Papilo → cuOpt run_presolve) and optionally assert +// upper bounds on the reduced size. Pass std::numeric_limits::max() for a +// dimension to skip that check. +static void run_presolve_size_check(const char* relative_mps_path, + int max_vars = std::numeric_limits::max(), + int max_rows = std::numeric_limits::max()) +{ + const raft::handle_t handle_{}; + auto model = io::read_mps(make_path_absolute(relative_mps_path), + /*fixed_format=*/false); + auto op_problem = mps_data_model_to_optimization_problem(&handle_, model); + sort_csr(op_problem); + + mip_solver_settings_t settings{}; + settings.presolver = presolver_t::Papilo; + settings.probing = true; + settings.block_bve = true; + + auto papilo = std::make_unique>(); + auto result = papilo->apply_presolve_from_op_problem(op_problem, + problem_category_t::MIP, + settings.presolver, + /*dual_postsolve=*/false, + settings.tolerances.absolute_tolerance, + settings.tolerances.relative_tolerance, + /*time_limit=*/60.0, + /*num_cpu_threads=*/0); + ASSERT_NE(result.status, mip::third_party_presolve_status_t::INFEASIBLE) + << relative_mps_path << " infeasible after Papilo"; + ASSERT_NE(result.status, mip::third_party_presolve_status_t::UNBNDORINFEAS) + << relative_mps_path << " unbounded-or-infeasible after Papilo"; + ASSERT_NE(result.status, mip::third_party_presolve_status_t::UNBOUNDED) + << relative_mps_path << " unbounded after Papilo"; + + mip::problem_t problem(result.reduced_problem); + problem.set_papilo_presolve_data(papilo.get(), + result.reduced_to_original_map, + result.original_to_reduced_map, + op_problem.get_n_variables()); + problem.set_implied_integers(result.implied_integer_indices); + problem.preprocess_problem(); + mip::trivial_presolve(problem, /*remap_cache_ids=*/true); // mirrors solve.cu's setup + + cuopt::timer_t timer(120.0); + mip::mip_solver_t solver(problem, settings, timer); + problem.tolerances = settings.get_tolerances(); + mip::diversity_manager_t dm(solver.context); + + bool presolve_ok = false; + with_mip_omp_team([&]() { presolve_ok = dm.run_presolve(/*time_limit=*/60.0, timer); }); + + ASSERT_TRUE(presolve_ok) << relative_mps_path << " cuOpt run_presolve failed"; + if (max_vars != std::numeric_limits::max()) { + EXPECT_LT(problem.n_variables, max_vars) + << relative_mps_path << " reduced n_variables=" << problem.n_variables; + } + if (max_rows != std::numeric_limits::max()) { + EXPECT_LT(problem.n_constraints, max_rows) + << relative_mps_path << " reduced n_constraints=" << problem.n_constraints; + } +} + +TEST(block_bve_presolve, bnatt400_reduces_below_500_vars) +{ + run_presolve_size_check("mip/bnatt400.mps", /*max_vars=*/500); +} + +TEST(block_bve_presolve, bnatt500_reduces_below_500_vars) +{ + run_presolve_size_check("mip/bnatt500.mps", /*max_vars=*/500); +} + +} // namespace cuopt::mathematical_optimization::test diff --git a/cpp/tests/mip/cuts_test.cu b/cpp/tests/mip/cuts_test.cu index b4fc3e8cc7..5af0754e3d 100644 --- a/cpp/tests/mip/cuts_test.cu +++ b/cpp/tests/mip/cuts_test.cu @@ -422,7 +422,7 @@ void disable_non_clique_cuts(mip_solver_settings_t& settings) void disable_non_zero_half_cuts(mip_solver_settings_t& settings) { - settings.clique_cuts = 1; + settings.clique_cuts = 0; settings.zero_half_cuts = 1; settings.max_cut_passes = 10; settings.mixed_integer_gomory_cuts = 0; @@ -1482,6 +1482,57 @@ TEST(cuts, zero_half_unit_separator_simple_pentagon) EXPECT_TRUE(found); } +TEST(cuts, zero_half_unit_mod2_row_finder_single_pair_and_four_row_dependencies) +{ + // Empty parity with odd rhs is a one-row zero-half aggregation. + { + const std::vector> parity_rows = {{}}; + const std::vector rhs_parity = {1}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), std::vector{0}); + } + + // Equal parity and opposite rhs form a two-row dependency. + { + const std::vector> parity_rows = {{0, 2}, {0, 2}}; + const std::vector rhs_parity = {0, 1}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), (std::vector{0, 1})); + } + + // Four edges of an even cycle cancel in GF(2); the odd aggregate rhs makes + // the dependency eligible for a zero-half cut. + { + const std::vector> parity_rows = {{0, 1}, {1, 2}, {2, 3}, {0, 3}}; + const std::vector rhs_parity = {1, 0, 0, 0}; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 8, 8); + ASSERT_EQ(combinations.size(), 1); + EXPECT_EQ(combinations.front(), (std::vector{0, 1, 2, 3})); + } +} + +TEST(cuts, zero_half_unit_mod2_row_finder_stops_at_work_limit) +{ + std::vector support(64); + std::iota(support.begin(), support.end(), 0); + const std::vector> parity_rows(256, support); + const std::vector rhs_parity(256, 0); + + constexpr double max_work = 18900.0; + double work = 0.0; + const auto combinations = + mip::find_mod2_row_combinations_for_test(parity_rows, rhs_parity, 64, 1000, max_work, &work); + + EXPECT_TRUE(combinations.empty()); + EXPECT_GT(work, max_work); + EXPECT_LT(work, 19100.0); +} + TEST(cuts, zero_half_unit_separator_no_cycle_for_4_cycle) { // Even cycle: 0-1-2-3-0 @@ -1615,6 +1666,28 @@ TEST(cuts, zero_half_end_to_end_pentagon_tightens_lp_relaxation) EXPECT_NEAR(mip_solution.get_objective_value(), -2.0, kCliqueTestTol); } +TEST(cuts, zero_half_end_to_end_general_row_parity_closes_triangle_root_gap) +{ + const raft::handle_t handle{}; + auto mip_problem = create_pairwise_triangle_set_packing_problem(); + + mip_solver_settings_t settings; + settings.time_limit = 10.0; + settings.presolver = presolver_t::None; + settings.node_limit = 0; + disable_non_zero_half_cuts(settings); + + benchmark_info_t benchmark_info; + settings.benchmark_info_ptr = &benchmark_info; + auto mip_solution = solve_mip(&handle, mip_problem, settings); + + EXPECT_NE(mip_solution.get_termination_status(), mip_termination_status_t::Infeasible); + ASSERT_FALSE(std::isnan(benchmark_info.root_lp_no_cuts)); + ASSERT_FALSE(std::isnan(benchmark_info.root_lp_with_cuts)); + EXPECT_NEAR(benchmark_info.root_lp_no_cuts, -1.5, kCliqueTestTol); + EXPECT_NEAR(benchmark_info.root_lp_with_cuts, -1.0, kCliqueTestTol); +} + TEST(cuts, zero_half_unit_separator_seven_cycle_violated_below_half) { // 7-cycle: 0-1-2-3-4-5-6-0, all weights 0.4. Each edge weight = (1-0.4-0.4)/2 = 0.1 diff --git a/cpp/tests/mip/miplib_test.cu b/cpp/tests/mip/miplib_test.cu index 924763c437..3bdd6f51ff 100644 --- a/cpp/tests/mip/miplib_test.cu +++ b/cpp/tests/mip/miplib_test.cu @@ -97,7 +97,7 @@ TEST(mip_solve, node_limit_test) { mip_solver_settings_t settings; settings.node_limit = 1000; - settings.time_limit = 60; + settings.time_limit = 120; settings.num_cpu_threads = 8; double expect_obj = 3.8151140644999992e+02; diff --git a/datasets/mip/block_bve/all_feasible_projection.mps b/datasets/mip/block_bve/all_feasible_projection.mps new file mode 100644 index 0000000000..ef03aa9eb2 --- /dev/null +++ b/datasets/mip/block_bve/all_feasible_projection.mps @@ -0,0 +1,25 @@ +NAME BVE_ALL_FEASIBLE +ROWS + N Obj + L r0 + L r1 + G r2 +COLUMNS + MARK0000 'MARKER' 'INTORG' + a r0 1 + a r1 1 + a r2 1 + b Obj 1 + b r0 -1 + b r2 -1 + c Obj 1 + c r1 -1 + c r2 -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r2 -1 +BOUNDS + BV BOUND a + BV BOUND b + BV BOUND c +ENDATA diff --git a/datasets/mip/block_bve/all_infeasible_projection.mps b/datasets/mip/block_bve/all_infeasible_projection.mps new file mode 100644 index 0000000000..d090b446c3 --- /dev/null +++ b/datasets/mip/block_bve/all_infeasible_projection.mps @@ -0,0 +1,54 @@ +NAME BVE_ALL_INFEASIBLE +ROWS + N Obj + G p000 + G p001 + G p010 + G p011 + G p100 + G p101 + G p110 + G p111 + L link +COLUMNS + MARK0000 'MARKER' 'INTORG' + a p000 1 + a p001 1 + a p010 1 + a p011 1 + a p100 -1 + a p101 -1 + a p110 -1 + a p111 -1 + a link 1 + b p000 1 + b p001 1 + b p010 -1 + b p011 -1 + b p100 1 + b p101 1 + b p110 -1 + b p111 -1 + c p000 1 + c p001 -1 + c p010 1 + c p011 -1 + c p100 1 + c p101 -1 + c p110 1 + c p111 -1 + x Obj 1 + x link -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V p000 1 + RHS_V p011 -1 + RHS_V p101 -1 + RHS_V p110 -1 + RHS_V p111 -2 +BOUNDS + BV BOUND a + BV BOUND b + BV BOUND c + BV BOUND x +ENDATA diff --git a/datasets/mip/block_bve/and_used.mps b/datasets/mip/block_bve/and_used.mps new file mode 100644 index 0000000000..3708601858 --- /dev/null +++ b/datasets/mip/block_bve/and_used.mps @@ -0,0 +1,30 @@ +NAME +ROWS + N Obj + L r0 + L r1 + G r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r0 -1 + c0 r2 -1 + c1 Obj -1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r3 -1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r2 -1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/aux_with_obj.mps b/datasets/mip/block_bve/aux_with_obj.mps new file mode 100644 index 0000000000..ad097e0b9c --- /dev/null +++ b/datasets/mip/block_bve/aux_with_obj.mps @@ -0,0 +1,28 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 3 + c2 r0 1 + c2 r1 1 + c2 r2 1 + c2 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 +ENDATA diff --git a/datasets/mip/block_bve/chain_or.mps b/datasets/mip/block_bve/chain_or.mps new file mode 100644 index 0000000000..c1e1e772f1 --- /dev/null +++ b/datasets/mip/block_bve/chain_or.mps @@ -0,0 +1,40 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + G r4 + L r5 + G r6 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r4 -1 + c2 r5 -1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 -1 + c3 r5 -1 + c4 r3 1 + c4 r4 1 + c4 r5 1 + c4 r6 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r6 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 +ENDATA diff --git a/datasets/mip/block_bve/heavy_reduce.mps b/datasets/mip/block_bve/heavy_reduce.mps new file mode 100644 index 0000000000..4fd5d4ed45 --- /dev/null +++ b/datasets/mip/block_bve/heavy_reduce.mps @@ -0,0 +1,54 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + G r4 + L r5 + L r6 + L r7 + G r8 + G r9 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c2 Obj 1 + c2 r3 -1 + c2 r5 -1 + c3 Obj 1 + c3 r4 -1 + c3 r5 -1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 -1 + c4 r8 -1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r7 -1 + c5 r8 -1 + c6 r6 1 + c6 r7 1 + c6 r8 1 + c6 r9 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r8 -1 + RHS_V r9 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 +ENDATA diff --git a/datasets/mip/block_bve/infeasible.mps b/datasets/mip/block_bve/infeasible.mps new file mode 100644 index 0000000000..2ecd497664 --- /dev/null +++ b/datasets/mip/block_bve/infeasible.mps @@ -0,0 +1,31 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + G r3 + L r4 + L r5 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r4 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r5 1 + c2 r0 1 + c2 r1 1 + c2 r2 1 + c2 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 +ENDATA diff --git a/datasets/mip/block_bve/mixed.mps b/datasets/mip/block_bve/mixed.mps new file mode 100644 index 0000000000..424aeb95a5 --- /dev/null +++ b/datasets/mip/block_bve/mixed.mps @@ -0,0 +1,55 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 + G r5 + L r6 + L r7 + L r8 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r7 -1 + c0 r8 1 + c1 Obj -1 + c1 r1 -1 + c1 r2 -1 + c1 r3 -1 + c1 r5 -1 + c1 r8 1 + c2 Obj 2 + c2 r4 -1 + c2 r5 -1 + c2 r8 1 + c3 Obj -1 + c3 r6 1 + c3 r8 1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r7 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r5 -1 + RHS_V r6 1 + RHS_V r8 3 +RANGES + RANGE r8 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/datasets/mip/block_bve/neq_used.mps b/datasets/mip/block_bve/neq_used.mps new file mode 100644 index 0000000000..3eff52e2c7 --- /dev/null +++ b/datasets/mip/block_bve/neq_used.mps @@ -0,0 +1,37 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r1 1 + c0 r2 -1 + c0 r3 1 + c1 Obj 1 + c1 r0 1 + c1 r1 -1 + c1 r2 -1 + c1 r3 1 + c2 Obj -3 + c2 r4 1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + c3 r4 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 2 + RHS_V r4 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/or_used.mps b/datasets/mip/block_bve/or_used.mps new file mode 100644 index 0000000000..18b3789a91 --- /dev/null +++ b/datasets/mip/block_bve/or_used.mps @@ -0,0 +1,34 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + G r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r4 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r4 1 + c2 Obj -2 + c2 r3 1 + c3 r0 1 + c3 r1 1 + c3 r2 1 + c3 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r3 1 + RHS_V r4 1 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 +ENDATA diff --git a/datasets/mip/block_bve/random_a.mps b/datasets/mip/block_bve/random_a.mps new file mode 100644 index 0000000000..b951a629e1 --- /dev/null +++ b/datasets/mip/block_bve/random_a.mps @@ -0,0 +1,37 @@ +NAME +ROWS + N Obj + G r0 + L r1 + G r2 + G r3 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r1 -2 + c0 r2 2 + c1 Obj 2 + c1 r3 -1 + c2 Obj -2 + c2 r2 -1 + c2 r3 2 + c3 r0 -2 + c3 r2 -2 + c4 Obj -2 + c4 r1 1 + c4 r2 -1 + c4 r3 -2 + c5 Obj 1 + c5 r0 2 + c5 r3 1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r1 -2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/datasets/mip/block_bve/random_b.mps b/datasets/mip/block_bve/random_b.mps new file mode 100644 index 0000000000..f81788b761 --- /dev/null +++ b/datasets/mip/block_bve/random_b.mps @@ -0,0 +1,54 @@ +NAME +ROWS + N Obj + G r0 + L r1 + L r2 + G r3 + G r4 + G r5 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -2 + c0 r0 2 + c1 Obj -2 + c1 r1 -1 + c2 Obj -2 + c2 r0 1 + c2 r1 -1 + c2 r3 2 + c3 r3 2 + c3 r4 2 + c4 Obj -1 + c4 r1 -2 + c4 r2 2 + c4 r5 2 + c5 r1 1 + c5 r2 -1 + c5 r4 1 + c6 r2 1 + c6 r3 -1 + c6 r4 -1 + c6 r5 1 + c7 Obj 2 + c7 r2 2 + c7 r4 2 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r0 -1 + RHS_V r1 -1 + RHS_V r2 5 + RHS_V r4 2 + RHS_V r5 1 +RANGES + RANGE r2 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 + BV BOUND c7 +ENDATA diff --git a/datasets/mip/block_bve/random_c.mps b/datasets/mip/block_bve/random_c.mps new file mode 100644 index 0000000000..83aaee35e0 --- /dev/null +++ b/datasets/mip/block_bve/random_c.mps @@ -0,0 +1,48 @@ +NAME +ROWS + N Obj + L r0 + L r1 + L r2 + G r3 + L r4 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj -1 + c0 r3 2 + c1 Obj 2 + c1 r1 -2 + c1 r3 -1 + c2 Obj 2 + c2 r4 1 + c3 Obj -1 + c3 r0 -1 + c3 r2 2 + c4 r0 -1 + c4 r1 1 + c4 r2 2 + c5 Obj 2 + c5 r0 2 + c5 r2 2 + c5 r4 2 + c6 Obj 1 + c6 r0 -1 + c6 r4 2 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r1 1 + RHS_V r2 3 + RHS_V r3 -2 + RHS_V r4 5 +RANGES + RANGE r0 2 + RANGE r4 3 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 + BV BOUND c6 +ENDATA diff --git a/datasets/mip/block_bve/two_gadgets.mps b/datasets/mip/block_bve/two_gadgets.mps new file mode 100644 index 0000000000..7bb2e51137 --- /dev/null +++ b/datasets/mip/block_bve/two_gadgets.mps @@ -0,0 +1,49 @@ +NAME +ROWS + N Obj + G r0 + G r1 + L r2 + L r3 + L r4 + G r5 + G r6 + G r7 +COLUMNS + MARK0000 'MARKER' 'INTORG' + c0 Obj 1 + c0 r0 -1 + c0 r2 -1 + c0 r7 1 + c1 Obj 1 + c1 r1 -1 + c1 r2 -1 + c1 r7 1 + c2 Obj 1 + c2 r3 -1 + c2 r5 -1 + c2 r7 1 + c3 Obj 1 + c3 r4 -1 + c3 r5 -1 + c3 r7 1 + c4 r0 1 + c4 r1 1 + c4 r2 1 + c4 r6 1 + c5 r3 1 + c5 r4 1 + c5 r5 1 + c5 r6 -1 + MARK0001 'MARKER' 'INTEND' +RHS + RHS_V r5 -1 + RHS_V r7 2 +BOUNDS + BV BOUND c0 + BV BOUND c1 + BV BOUND c2 + BV BOUND c3 + BV BOUND c4 + BV BOUND c5 +ENDATA diff --git a/python/cuopt/cuopt/tests/routing/test_batch_solve.py b/python/cuopt/cuopt/tests/routing/test_batch_solve.py index 5099c89cb1..31d09c202c 100644 --- a/python/cuopt/cuopt/tests/routing/test_batch_solve.py +++ b/python/cuopt/cuopt/tests/routing/test_batch_solve.py @@ -2,9 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import cudf -import cupy import numpy as np -import pytest from cuopt import routing @@ -18,12 +16,6 @@ def create_tsp_cost_matrix(n_locations): return cudf.DataFrame(cost_matrix) -@pytest.mark.skipif( - 13030 <= cupy.cuda.get_local_runtime_version() < 13040, - reason="block_copy destination overflow on CUDA 13.3, see " - "https://github.com/NVIDIA/cuopt/issues/1756. The device-side assert " - "aborts the process, so xfail cannot catch it.", -) def test_batch_solve_varying_sizes(): """Test batch solving TSPs of varying sizes.""" tsp_sizes = [ diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index aa488e064b..56a97c6585 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -171,6 +171,7 @@ cuopt/ - Keep operations stream-ordered - Follow existing RAFT/RMM patterns - No raw `new`/`delete` - use RMM allocators +- Prefer modern CCCL bit/math helpers in kernels (`cuda::bitfield_extract`, `cuda::bitmask`, pow2 utilities) over hand-rolled `%`/`/` by runtime powers of two — see [references/conventions.md](references/conventions.md) ## Build & Test @@ -224,7 +225,7 @@ For pre-commit setup, DCO sign-off (`git commit -s`), the fork-based PR workflow ## Coding Conventions -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`), test-impact rules, and 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), see [references/conventions.md](references/conventions.md). +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 @@ -232,6 +233,20 @@ Treat `#pragma omp task if(...) firstprivate(...)` with a non-trivial C++ captur When diagnosing OpenMP-only failures, test compiler/runtime pairs separately. A Clang + libomp pass exercises the `__kmpc_*` ABI and does not cover GCC + libomp's `GOMP_*` path; reduce suspicious cases to a direct runtime-ABI probe before attributing them to solver logic. +## PCG random number generator + +`cpp/src/utilities/pcgenerator.hpp` (`cuopt::pcgenerator_t`) is copied from RAFT's `PCGenerator` (`raft/random/detail/rng_device.cuh`), duplicated only because the RAFT header pulls in CUDA and therefore cannot be included from a `.cpp`. **Treat the generator core as frozen.** Do not "clean it up", modernise it, or swap it for ``: reproducibility under `settings.random_seed` and `settings.deterministic` holds only while the byte-for-byte output sequence is preserved, and the CPU copy must keep producing the same stream as the GPU one. Adding a *new* helper that consumes `next_u32()`/`next_double()` is fine; changing how those values are produced is not. + +Each of the following looks like a defect or an obvious simplification, and is neither. Leave them alone: + +- `stream = (subsequence << 1u) | 1u` in `set_seed`. A 2^64 LCG has full period only when its increment is odd — the `| 1u` is what guarantees that — and the `<< 1u` is why two subsequences must differ in their **low 63 bits** to get independent streams. +- The two `next(discard)` warm-up calls straddling `state += seed`. This is PCG's canonical seeding sequence, and it is what decorrelates *adjacent* seeds — which is exactly how cuOpt seeds workers (`settings.random_seed + pcgenerator_t::default_seed + rng_offset + worker_id`, `branch_and_bound/worker.hpp`). Collapsing it to `state = seed` makes neighbouring workers draw near-identical prefixes. +- The output permutation in `next_u32` (`>> 18u`, `^`, `>> 27u`, `rot = oldstate >> 59u`, `(-rot) & 31u`) — the PCG-XSH-RR constants for a 64→32-bit output. In particular `(-rot) & 31u` relies on unsigned wraparound; rewriting it as `32 - rot` is a shift by 32, i.e. undefined behaviour, whenever `rot == 0`. +- The multiplier `6364136223846793005ULL`, which appears in both `next_u32` and as `h` in `skipahead`. `skipahead` is the closed-form LCG jump (Brown's arbitrary-stride method) and matches N calls to `next_u32` only while the two constants are identical. +- The `>> 8` / `>> 11` in `next_float` / `next_double`. They yield exactly 24 and 53 mantissa bits, so the result lies in `[0, 1)`. Dividing by `UINT32_MAX` instead makes `1.0` reachable and breaks every `rng.next_double() * n` used as an index. +- The sign-bit masks in `next_i32` / `next_i64` (`& 0x7fffffff…`); callers rely on the results being non-negative. +- `uniform()`'s floating-point scaling and `shuffle()`'s Fisher-Yates order. `uniform()`'s slight bias is documented and accepted; substituting a modulo or `std::uniform_int_distribution` changes every sampled sequence. + ## Troubleshooting & CI For build/test pitfalls (Cython rebuild, OOM, CUDA driver mismatch, missing `nvcc`) and CI failure diagnostics (style checks, DCO failures, dependency drift), see [references/troubleshooting.md](references/troubleshooting.md). diff --git a/skills/cuopt-developer/references/conventions.md b/skills/cuopt-developer/references/conventions.md index e9963d4824..74fac403bf 100644 --- a/skills/cuopt-developer/references/conventions.md +++ b/skills/cuopt-developer/references/conventions.md @@ -55,9 +55,20 @@ This applies to all comment types: inline comments, block comments, suppression ## C++ Implementation Style -- Prefer direct loops or named helpers for performance-critical traversal logic. Reserve lambdas for - short predicates and callbacks; large local lambdas obscure control flow and can lead to repeated - scans. +- **Never write large lambdas inside a function body.** If a lambda is more than a short + predicate/comparator (roughly more than ~3–5 lines, or it has nested lambdas, local state, + or non-trivial control flow), extract it as a named free function, file-local helper in an + anonymous namespace, or private method. Large local lambdas obscure control flow, hide reuse, + and make work/time accounting harder to reason about. +- Prefer direct loops or named helpers for performance-critical traversal logic. Reserve + in-function lambdas for short predicates and callbacks only. +- Keep work-estimate and time-limit checks at phase or outer-loop boundaries. Accumulate the work + performed by cheap inner loops and charge it once when the phase completes; do not gate every + inner iteration. Do not separately test a sticky limit or raw estimate immediately before or + after `add_work_estimate` when that call already provides the gate for the same work. +- Charge the operation that is actually performed. For vector copies and sparse traversals, base + work on the number of visited or copied entries rather than only the number of containers. + Avoid charging the same traversal in both its caller and callee. ### Suppression comments (`// NOSONAR`, `// NOLINT`, etc.) @@ -126,12 +137,51 @@ signed subtraction (`std::vector v(static_cast(hi - lo) + 2, 0)`), the narrowing `size_t`→`i_t` in `static_cast(x.size())` (established style; keep it) +### Integer widths — prefer fixed-width types + +Prefer `` fixed-width types (`int32_t`, `int64_t`, `uint32_t`, …) over +plain `int` / `long` / `long long` when the value range or ABI width matters +(counts that can exceed 32 bits, device grid math, work estimates, file offsets). + +Avoid multi-word functional casts such as `long long(x)` in `.cu`/`.cuh` — they +confuse CUDA-aware tooling (`type name is not allowed`). Use a C-style cast to a +fixed-width type instead: `(int64_t)x`. + +```cpp +const long long total = long long(num) * long long(patterns); // ❌ +const int64_t total = (int64_t)num * (int64_t)patterns; // ✅ +``` + +Keep `i_t` / `f_t` for problem-index and numeric template parameters; use +`int64_t` (etc.) for host-side wide counters outside that abstraction. + ### CUDA Error Checking ```cpp RAFT_CUDA_TRY(cudaMemcpy(...)); ``` +### Prefer modern CCCL utilities in device code + +When writing or editing CUDA kernels, prefer CCCL / libcu++ helpers over hand-rolled +bit math, reductions, or integer tricks. They encode the PTX-friendly form and avoid +boilerplate that compilers often fail to recover from runtime values. + +Examples (CUDA 13 / CCCL 3.x era — headers already used elsewhere in `cpp/src`): + +| Need | Prefer | Instead of | +|------|--------|------------| +| Extract a bitfield / decode packed indices | `cuda::bitfield_extract` (``) | `%` / `/` by a runtime `1 << k` (nvcc usually will not strength-reduce those to mask/shift) | +| Build a contiguous bit mask | `cuda::bitmask` | Hand-written `((1u << w) - 1u) << start` | +| Test / round to power of two | `cuda::is_power_of_two`, `next_power_of_two`, `prev_power_of_two` (``), or `cuda::std::has_single_bit` / `bit_ceil` / `bit_floor` (``) | Ad-hoc `(x & (x - 1)) == 0` / manual ceil loops | +| Divide/mod by a value that is constant for a launch (or across many ops) but not a compile-time constant | `cuda::fast_mod_div` (``) — construct on the host (or once), pass into the kernel, use `/` `%` / `cuda::div` | Hot-path `idiv` / handwritten libdivide magic | +| Warp/block algorithms | CUB / CCCL / RAFT primitives already used in-tree | Homegrown shared-memory reductions when an existing primitive fits | + +Docs: [CCCL bit extensions](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/bit.html), +[pow2 helpers](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/math/pow2.html), +[`cuda::fast_mod_div`](https://nvidia.github.io/cccl/unstable/libcudacxx/extended_api/math/fast_mod_div.html). +Check signatures in the installed headers rather than guessing — APIs evolve with CCCL. + ## Memory Management ```cpp @@ -147,6 +197,24 @@ rmm::device_uvector data(100, stream); Read existing code in `cpp/src/` for real examples of RMM allocation, stream-ordering, RAFT utilities, and kernel launch patterns. +### Bypassing `ins_vector`: credit the bytes back to the wrapper + +The instrumented accessors record a load per element read, and the counter lives in the +wrapper while the data lives in the vector's buffer. The compiler cannot prove those do not +alias, so the counter round-trips through memory every iteration and serializes the loop. +That cost is measurable in the innermost scoring loops. + +Two instrumentation-free paths to the same buffers already exist: `data()` on the wrapper +returns the raw pointer without recording, and the spans published on `fj_cpu.view` alias the +same allocations. + +When you take either path, add the skipped bytes back into the wrapper you bypassed — +`byte_loads` and `byte_stores` are public `mutable size_t` on +`memory_instrumentation_base_t`, so one `+= n * sizeof(element)` above the loop replaces N +per-element records. Do not route them into a separate counter: the byte totals feed the +deterministic work-unit proxy, and crediting the wrapper keeps both `collect()` and +`collect_per_wrapper()` correct and leaves the work-unit calibration untouched. + ## Test Impact Check **Before any behavioral change, ask:** @@ -158,3 +226,15 @@ Read existing code in `cpp/src/` for real examples of RMM allocation, stream-ord - Python pytest: `python/.../tests/` **Add at least one regression test for new behavior.** + +When a new MIP test loads a MIPLIB instance (e.g. via `make_path_absolute("mip/.mps")`), +that instance must appear in `datasets/mip/download_miplib_test_dataset.sh`'s `INSTANCES` +list. CI and local setups only fetch that allowlist — an unlisted name fails at parse time +with a missing-file error even though the test itself is correct. Add the basename there as part of the same change that introduces the test. + +Calling cuOpt MIP internals that use OpenMP taskloops (notably `diversity_manager::run_presolve` +→ `compute_probing_cache`) from a plain gtest must open an OMP team first, the same way +`solve_mip` does (`#pragma omp parallel num_threads(...)` + `#pragma omp masked`, with +`omp_set_max_active_levels(2)` if needed). Probing sizes its pool as +`omp_get_num_threads() - 1`; outside a parallel region that is 0 and probing becomes a silent +no-op (Papilo size unchanged, test finishes in a few hundred ms). diff --git a/thirdparty/THIRD_PARTY_LICENSES b/thirdparty/THIRD_PARTY_LICENSES index 7424a65232..15d5a08e00 100644 --- a/thirdparty/THIRD_PARTY_LICENSES +++ b/thirdparty/THIRD_PARTY_LICENSES @@ -597,3 +597,217 @@ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----------------------------------------------------------------------------------------- + +== highway Apache-2.0 + +Files: cpp/build/_deps/highway-src + +Copyright (c) The Highway Project Authors. All rights reserved. + +Highway is dual-licensed under the Apache License 2.0 or the BSD 3-Clause License; +cuOpt elects the Apache License 2.0, reproduced below. + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.