diff --git a/cpp/include/cuopt/mathematical_optimization/constants.h b/cpp/include/cuopt/mathematical_optimization/constants.h index 86ed6965c8..04ac3355ab 100644 --- a/cpp/include/cuopt/mathematical_optimization/constants.h +++ b/cpp/include/cuopt/mathematical_optimization/constants.h @@ -98,8 +98,9 @@ #define CUOPT_MIP_HYPER_HEURISTIC_POPULATION_SIZE "mip_hyper_heuristic_population_size" #define CUOPT_MIP_HYPER_HEURISTIC_NUM_CPUFJ_THREADS "mip_hyper_heuristic_num_cpufj_threads" -#define CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_TIME_RATIO "mip_hyper_heuristic_presolve_time_ratio" -#define CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_MAX_TIME "mip_hyper_heuristic_presolve_max_time" +#define CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_MAX_ROUNDS "mip_hyper_heuristic_presolve_max_rounds" +#define CUOPT_MIP_HYPER_HEURISTIC_PAPILO_PROBING_MAX_BADGESIZE \ + "mip_hyper_heuristic_papilo_probing_max_badgesize" #define CUOPT_MIP_HYPER_HEURISTIC_ROOT_LP_TIME_RATIO "mip_hyper_heuristic_root_lp_time_ratio" #define CUOPT_MIP_HYPER_HEURISTIC_ROOT_LP_MAX_TIME "mip_hyper_heuristic_root_lp_max_time" #define CUOPT_MIP_HYPER_HEURISTIC_RINS_TIME_LIMIT "mip_hyper_heuristic_rins_time_limit" diff --git a/cpp/include/cuopt/mathematical_optimization/mip/heuristics_hyper_params.hpp b/cpp/include/cuopt/mathematical_optimization/mip/heuristics_hyper_params.hpp index e3fd891e83..af72805e40 100644 --- a/cpp/include/cuopt/mathematical_optimization/mip/heuristics_hyper_params.hpp +++ b/cpp/include/cuopt/mathematical_optimization/mip/heuristics_hyper_params.hpp @@ -19,10 +19,15 @@ namespace cuopt::mathematical_optimization { */ template struct mip_heuristics_hyper_params_t { - i_t population_size = 32; // max solutions in pool - i_t num_cpufj_threads = 8; // parallel CPU FJ climbers - f_t presolve_time_ratio = 0.1; // fraction of total time for presolve - f_t presolve_max_time = 60.0; // hard cap on presolve seconds + i_t population_size = 32; // max solutions in pool + i_t num_cpufj_threads = 8; // parallel CPU FJ climbers + + // Presolve budgeting. Both are derived from the problem's dimensions and structure by default + // (see presolve_budget_policy.hpp); a negative value asks for that rule, and any other value + // overrides it, where <=0 removes the cap entirely. + i_t presolve_max_rounds = -1; // Papilo presolve rounds cap + i_t papilo_probing_max_badgesize = -1; // ceiling on Papilo's probing.minbadgesize + f_t root_lp_time_ratio = 0.1; // fraction of total time for root LP f_t root_lp_max_time = 15.0; // hard cap on root LP seconds f_t rins_time_limit = 3.0; // per-call RINS sub-MIP time diff --git a/cpp/src/barrier/barrier.cu b/cpp/src/barrier/barrier.cu index 5db791f7e9..edc7a00d1d 100644 --- a/cpp/src/barrier/barrier.cu +++ b/cpp/src/barrier/barrier.cu @@ -4530,7 +4530,10 @@ lp_status_t barrier_solver_t::solve(f_t start_time, lp_solution_t #include +#include + +#include +#include + namespace cuopt::mathematical_optimization::barrier { template @@ -140,6 +145,15 @@ void multiply_kernels(raft::handle_t const* handle, int64_t ADAT_num_rows, ADAT_num_cols, ADAT_nnz1; RAFT_CUSPARSE_TRY( cusparseSpMatGetSize(cusparse_data.matADAT_descr, &ADAT_num_rows, &ADAT_num_cols, &ADAT_nnz1)); + // cuSPARSE sizes the product in 64 bits while the CSR arrays are indexed by i_t; narrowing would + // reach RMM as a negative count and surface as an unrelated device_uvector overflow. + if (ADAT_nnz1 > std::numeric_limits::max()) { + throw rmm::out_of_memory( + "ADAT needs " + std::to_string(ADAT_nnz1) + " nonzeros over " + + std::to_string(ADAT_num_rows) + " rows, past the " + + std::to_string(std::numeric_limits::max()) + + " its index type can address: the normal equations are too dense for this problem"); + } ADAT.resize_to_nnz(ADAT_nnz1, handle->get_stream()); thrust::fill(rmm::exec_policy(handle->get_stream()), ADAT.x.begin(), ADAT.x.end(), 0.0); diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 64dcdd20e5..2fb7896027 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -755,12 +755,9 @@ mip_settings: field_num: 36 type: int32 optional: true - - presolve_time_ratio: - field_num: 37 - optional: true - - presolve_max_time: - field_num: 38 - optional: true + # 37 and 38 held presolve_time_ratio and presolve_max_time, removed when + # presolve stopped taking a wall budget. Do not reuse: an older client + # still sends them on those numbers. - root_lp_time_ratio: field_num: 39 optional: true @@ -805,6 +802,14 @@ mip_settings: - related_vars_time_limit: field_num: 51 optional: true + - presolve_max_rounds: + field_num: 53 + type: int32 + optional: true + - papilo_probing_max_badgesize: + field_num: 54 + type: int32 + optional: true # ───────────────────────────────────────────────────────────────────────────── # Optimization Problem (cpu_optimization_problem_t) diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 89804e2fa3..4b1e36d134 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -234,8 +234,6 @@ message MIPSolverSettings { optional double semi_continuous_big_m = 34; optional int32 population_size = 35; optional int32 num_cpufj_threads = 36; - optional double presolve_time_ratio = 37; - optional double presolve_max_time = 38; optional double root_lp_time_ratio = 39; optional double root_lp_max_time = 40; optional double rins_time_limit = 41; @@ -250,6 +248,8 @@ message MIPSolverSettings { optional double relaxed_lp_time_limit = 50; optional double related_vars_time_limit = 51; optional int32 zero_half_cuts = 52; + optional int32 presolve_max_rounds = 53; + optional int32 papilo_probing_max_badgesize = 54; } message PDLPWarmStartData { diff --git a/cpp/src/grpc/codegen/generated/generated_mip_settings_to_proto.inc b/cpp/src/grpc/codegen/generated/generated_mip_settings_to_proto.inc index 12d95aa6df..e92e156053 100644 --- a/cpp/src/grpc/codegen/generated/generated_mip_settings_to_proto.inc +++ b/cpp/src/grpc/codegen/generated/generated_mip_settings_to_proto.inc @@ -43,8 +43,6 @@ pb_settings->set_semi_continuous_big_m(settings.semi_continuous_big_m); pb_settings->set_population_size(settings.heuristic_params.population_size); pb_settings->set_num_cpufj_threads(settings.heuristic_params.num_cpufj_threads); - pb_settings->set_presolve_time_ratio(settings.heuristic_params.presolve_time_ratio); - pb_settings->set_presolve_max_time(settings.heuristic_params.presolve_max_time); pb_settings->set_root_lp_time_ratio(settings.heuristic_params.root_lp_time_ratio); pb_settings->set_root_lp_max_time(settings.heuristic_params.root_lp_max_time); pb_settings->set_rins_time_limit(settings.heuristic_params.rins_time_limit); @@ -58,3 +56,5 @@ pb_settings->set_cycle_detection_length(settings.heuristic_params.cycle_detection_length); pb_settings->set_relaxed_lp_time_limit(settings.heuristic_params.relaxed_lp_time_limit); pb_settings->set_related_vars_time_limit(settings.heuristic_params.related_vars_time_limit); + pb_settings->set_presolve_max_rounds(settings.heuristic_params.presolve_max_rounds); + pb_settings->set_papilo_probing_max_badgesize(settings.heuristic_params.papilo_probing_max_badgesize); diff --git a/cpp/src/grpc/codegen/generated/generated_proto_to_mip_settings.inc b/cpp/src/grpc/codegen/generated/generated_proto_to_mip_settings.inc index e14ffa1ae0..04241b3407 100644 --- a/cpp/src/grpc/codegen/generated/generated_proto_to_mip_settings.inc +++ b/cpp/src/grpc/codegen/generated/generated_proto_to_mip_settings.inc @@ -107,12 +107,6 @@ if (pb_settings.has_num_cpufj_threads()) { settings.heuristic_params.num_cpufj_threads = pb_settings.num_cpufj_threads(); } - if (pb_settings.has_presolve_time_ratio()) { - settings.heuristic_params.presolve_time_ratio = pb_settings.presolve_time_ratio(); - } - if (pb_settings.has_presolve_max_time()) { - settings.heuristic_params.presolve_max_time = pb_settings.presolve_max_time(); - } if (pb_settings.has_root_lp_time_ratio()) { settings.heuristic_params.root_lp_time_ratio = pb_settings.root_lp_time_ratio(); } @@ -152,3 +146,9 @@ if (pb_settings.has_related_vars_time_limit()) { settings.heuristic_params.related_vars_time_limit = pb_settings.related_vars_time_limit(); } + if (pb_settings.has_presolve_max_rounds()) { + settings.heuristic_params.presolve_max_rounds = pb_settings.presolve_max_rounds(); + } + if (pb_settings.has_papilo_probing_max_badgesize()) { + settings.heuristic_params.papilo_probing_max_badgesize = pb_settings.papilo_probing_max_badgesize(); + } diff --git a/cpp/src/math_optimization/solver_settings.cu b/cpp/src/math_optimization/solver_settings.cu index 125280e549..bfdf44e195 100644 --- a/cpp/src/math_optimization/solver_settings.cu +++ b/cpp/src/math_optimization/solver_settings.cu @@ -105,8 +105,6 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings {CUOPT_MIP_CUT_MIN_ORTHOGONALITY, &mip_settings.cut_min_orthogonality, f_t(0.0), f_t(1.0), f_t(0.5)}, {CUOPT_BARRIER_STEP_SCALE, &pdlp_settings.barrier_step_scale, f_t(0.5), f_t(0.9999), f_t(0.9)}, // MIP heuristic hyper-parameters (hidden from default --help: name contains "hyper_") - {CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_TIME_RATIO, &mip_settings.heuristic_params.presolve_time_ratio, f_t(0.0), f_t(1.0), f_t(0.1), "fraction of total time for presolve"}, - {CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_MAX_TIME, &mip_settings.heuristic_params.presolve_max_time, f_t(0.0), std::numeric_limits::infinity(), f_t(60.0), "hard cap on presolve seconds"}, {CUOPT_MIP_HYPER_HEURISTIC_ROOT_LP_TIME_RATIO, &mip_settings.heuristic_params.root_lp_time_ratio, f_t(0.0), f_t(1.0), f_t(0.1), "fraction of total time for root LP"}, {CUOPT_MIP_HYPER_HEURISTIC_ROOT_LP_MAX_TIME, &mip_settings.heuristic_params.root_lp_max_time, f_t(0.0), std::numeric_limits::infinity(), f_t(15.0), "hard cap on root LP seconds"}, {CUOPT_MIP_HYPER_HEURISTIC_RINS_TIME_LIMIT, &mip_settings.heuristic_params.rins_time_limit, f_t(0.0), std::numeric_limits::infinity(), f_t(3.0), "per-call RINS sub-MIP time"}, @@ -169,6 +167,8 @@ solver_settings_t::solver_settings_t() : pdlp_settings(), mip_settings // MIP heuristic hyper-parameters (hidden from default --help: name contains "hyper_") {CUOPT_MIP_HYPER_HEURISTIC_POPULATION_SIZE, &mip_settings.heuristic_params.population_size, 1, std::numeric_limits::max(), 32, "max solutions in pool"}, {CUOPT_MIP_HYPER_HEURISTIC_NUM_CPUFJ_THREADS, &mip_settings.heuristic_params.num_cpufj_threads, 0, std::numeric_limits::max(), 8, "parallel CPU FJ climbers"}, + {CUOPT_MIP_HYPER_HEURISTIC_PRESOLVE_MAX_ROUNDS, &mip_settings.heuristic_params.presolve_max_rounds, -1, std::numeric_limits::max(), -1, "Papilo presolve rounds cap (<0 derives it from the problem, 0 keeps Papilo default)"}, + {CUOPT_MIP_HYPER_HEURISTIC_PAPILO_PROBING_MAX_BADGESIZE, &mip_settings.heuristic_params.papilo_probing_max_badgesize, -1, std::numeric_limits::max(), -1, "ceiling on Papilo probing.minbadgesize (<0 derives it from the problem, 0 leaves it uncapped)"}, {CUOPT_MIP_HYPER_HEURISTIC_STAGNATION_TRIGGER, &mip_settings.heuristic_params.stagnation_trigger, 1, std::numeric_limits::max(), 3, "FP loops w/o improvement before recombination"}, {CUOPT_MIP_HYPER_HEURISTIC_MAX_ITERS_WITHOUT_IMPROVEMENT, &mip_settings.heuristic_params.max_iterations_without_improvement, 1, std::numeric_limits::max(), 8, "diversity step depth after stagnation"}, {CUOPT_MIP_HYPER_HEURISTIC_N_OF_MINIMUMS_FOR_EXIT, &mip_settings.heuristic_params.n_of_minimums_for_exit, 1, std::numeric_limits::max(), 7000, "FJ baseline local-minima exit threshold"}, diff --git a/cpp/src/mip_heuristics/diversity/diversity_config.hpp b/cpp/src/mip_heuristics/diversity/diversity_config.hpp index ec6998c464..3f71f38bc9 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_config.hpp +++ b/cpp/src/mip_heuristics/diversity/diversity_config.hpp @@ -12,8 +12,6 @@ namespace cuopt::mathematical_optimization::mip { struct diversity_config_t { - double time_ratio_of_probing_cache = 0.1; - double max_time_on_probing = 60.0; int max_var_diff = 256; double default_time_limit = 10.; int initial_island_size = 3; diff --git a/cpp/src/mip_heuristics/diversity/diversity_manager.cu b/cpp/src/mip_heuristics/diversity/diversity_manager.cu index 61c90944f4..9d70ae17ee 100644 --- a/cpp/src/mip_heuristics/diversity/diversity_manager.cu +++ b/cpp/src/mip_heuristics/diversity/diversity_manager.cu @@ -20,6 +20,9 @@ #include +#include +#include +#include #include #include @@ -294,10 +297,10 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ if (termination_criterion_t::NO_UPDATE != term_crit) { ls.constraint_prop.bounds_update.set_updated_bounds(*problem_ptr); } - bool run_probing_cache = !fj_only_run; - // Don't run probing cache in deterministic mode yet as neither B&B nor CPUFJ need it - // and it doesn't make use of work units yet - if (context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC) { run_probing_cache = false; } + const auto& hp = context.settings.heuristic_params; + const auto probing_features = probing_presolve_features(*problem_ptr); + const auto probing_budget = evaluate_presolve_budget(hp, probing_features); + bool run_probing_cache = !fj_only_run; // Allow the user to disable the probing-cache step of cuOpt's internal presolve // independently of the higher-level presolver setting. if (!context.settings.probing) { @@ -305,13 +308,20 @@ bool diversity_manager_t::run_presolve(f_t time_limit, timer_t global_ run_probing_cache = false; } if (run_probing_cache) { - // Run probing cache before trivial presolve to discover variable implications - const f_t max_time_on_probing = diversity_config.max_time_on_probing; - f_t time_for_probing_cache = std::min(max_time_on_probing, time_limit); + log_presolve_budget("PROBING", probing_features, probing_budget); + 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(); // this function computes probing cache, finds singletons, substitutions and changes the problem - bool problem_is_infeasible = - compute_probing_cache(ls.constraint_prop.bounds_update, *problem_ptr, probing_timer); + bool problem_is_infeasible = compute_probing_cache(ls.constraint_prop.bounds_update, + *problem_ptr, + probing_timer, + probing_budget.probing_work_limit, + (size_t)probing_budget.probing_step_size); + problem_ptr->handle_ptr->sync_stream(); + CUOPT_LOG_DEBUG( + "PRESOLVE_PROBING_WALL wall=%.3f", + std::chrono::duration(std::chrono::steady_clock::now() - probing_t0).count()); if (problem_is_infeasible) { return false; } } const bool remap_cache_ids = true; diff --git a/cpp/src/mip_heuristics/presolve/multi_probe.cu b/cpp/src/mip_heuristics/presolve/multi_probe.cu index f1adf28650..394d89f580 100644 --- a/cpp/src/mip_heuristics/presolve/multi_probe.cu +++ b/cpp/src/mip_heuristics/presolve/multi_probe.cu @@ -306,6 +306,7 @@ termination_criterion_t multi_probe_t::bound_update_loop(problem_tsync_stream(); if (compute_stats) { upd_0.init_changed_constraints(handle_ptr); diff --git a/cpp/src/mip_heuristics/presolve/multi_probe.cuh b/cpp/src/mip_heuristics/presolve/multi_probe.cuh index b4227281b6..0dfa780694 100644 --- a/cpp/src/mip_heuristics/presolve/multi_probe.cuh +++ b/cpp/src/mip_heuristics/presolve/multi_probe.cuh @@ -75,6 +75,8 @@ class multi_probe_t { bool skip_0; bool skip_1; settings_t settings; + // Per-task iteration count; a shared counter would race and make the budget nondeterministic. + double* local_iter_accumulator = nullptr; bool compute_stats = true; bool init_changed_constraints = true; i_t infeas_constraints_count_0 = 0; diff --git a/cpp/src/mip_heuristics/presolve/presolve_budget_policy.hpp b/cpp/src/mip_heuristics/presolve/presolve_budget_policy.hpp new file mode 100644 index 0000000000..1b18fdec96 --- /dev/null +++ b/cpp/src/mip_heuristics/presolve/presolve_budget_policy.hpp @@ -0,0 +1,170 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#pragma once + +#include + +#include + +#include +#include + +namespace cuopt::mathematical_optimization::mip { + +// Work the probing loop charges per unit of effort. These are exact counts rather than timings, +// which is what makes the budget reproducible, and probing_work_scale below is expressed against +// them -- changing either invalidates it. +inline constexpr double probing_probe_work = 0.02; // per probed variable, host overhead +inline constexpr double probing_iter_work = 0.01; // per multi-probe propagation iteration + +// Probing work allowed per unit of the cost proxy below; dividing by that proxy is what turns it +// into a per-instance work ceiling. It is a work coefficient, and nothing here converts it to +// seconds. Since the wall cap was removed this is the only bound on probing, which is what the +// value was picked to survive: over 240 instances it stopped every run before the 120s wall could +// fire, worst case 44.7s, while looser scales of 4e8 and 1e9 needed that wall on 2 and 8 instances +// and spent 2-3x the total probing time to do it. +// +// Tight enough to bound time is also tight enough to truncate, and that trade is deliberate: +// probing takes its time from branch and bound, and the truncation measured neutral on solution +// quality (11.78 mean error against 12.01, inside the 0.53 run-to-run noise). +// +// A larger scale is not the way to buy coverage back, and that has now been measured rather than +// inferred: raising it 25% reached only 67 of 240 instances (the rest cannot resolve the change at +// step 128 below), spent 23% more probing time on those, and left their median error delta at +// exactly 0.00. The proxy predicts throughput only to within ~340x and one instance (nw04) pins the +// scale under every reshaping tried, including refitting the exponent to the measured nnz^0.65. +// Beyond that the residual is not explained by any structural feature; it needs throughput measured +// during probing rather than predicted from the problem. +inline constexpr double probing_work_scale = 1.5e8; + +// Probed variables between work-budget checks, i.e. the granularity at which the budget can be +// enforced. Work is only folded in at the step barrier, so too large a step runs unbudgeted. +inline constexpr int probing_budget_step_size = 128; + +// cuOpt forces probing.minbadgesize to ncols/2 to stop Papilo aborting probing on its own work +// budget, so the first badge overshoots that budget in a single pass and the badge cap is the only +// thing left bounding it. How much one badge costs scales with the nonzeros a probing sweep visits, +// n_bin * avg_col_len, so the clamp applies only where that cost is large enough to matter. +// +// Above this threshold the badge stops paying for itself: square47 (2.7e7) went from 40.6s at badge +// 1024 to 8.1s at 32 with a bit-identical reduced problem. Nearer the threshold the reduction is +// worth more than the time -- triptim1 (3.5e5) removes 669 rows at 1024 against 545 at 32, and +// clamping it regressed the instance -- and the same holds below it for mzzv11, 30n20b8 and air05. +// +// Being exempt still means a bounded badge. Leaving it uncapped hands Papilo ncols/2, which on wide +// problems is enormous and costs most of the presolve budget for nothing: rail01 went to badge +// 58763 and Papilo 12.1s -> 49.2s. 1024 keeps the reduction that mattered on triptim1 and mzzv11. +inline constexpr double papilo_badge_cost_threshold = 5.0e5; +inline constexpr int papilo_badge_clamped = 32; +inline constexpr int papilo_badge_exempt = 1024; + +// Dimensions plus cheap structural ratios. Both presolve stages populate this from whatever problem +// representation they hold: Papilo from the original problem before any reduction, the cuOpt +// probing cache from the Papilo-reduced problem. The two therefore see different feature values for +// the same instance, which is intended -- each budget should follow the problem it actually +// operates on. +struct presolve_features_t { + double n_vars{0}; + double n_cons{0}; + double nnz{0}; + double n_int{0}; + double n_bin{0}; + double max_row_len{0}; + + double avg_row_len() const { return n_cons > 0 ? nnz / n_cons : 0.0; } + double avg_col_len() const { return n_vars > 0 ? nnz / n_vars : 0.0; } + double density() const { return (n_vars > 0 && n_cons > 0) ? nnz / (n_vars * n_cons) : 0.0; } + double int_frac() const { return n_vars > 0 ? n_int / n_vars : 0.0; } + double bin_frac() const { return n_vars > 0 ? n_bin / n_vars : 0.0; } +}; + +struct presolve_budget_t { + // <=0 leaves Papilo's own default (unlimited rounds). + int papilo_max_rounds{-1}; + // <=0 leaves probing.minbadgesize uncapped at max(ncols/2, 32). + int papilo_max_badgesize{-1}; + // Probing-cache budget in work units: a reproducible count of probing effort, not a time + // estimate. + double probing_work_limit{std::numeric_limits::infinity()}; + int probing_step_size{probing_budget_step_size}; +}; + +// Derives both presolve stages' budgets from the problem's dimensions and structure. Rounds and +// badge accept an explicit override from the hyper-parameters; a negative setting asks for the +// measured rule below, and any other value is passed through, where <=0 removes the cap. +template +presolve_budget_t evaluate_presolve_budget(const mip_heuristics_hyper_params_t& hp, + const presolve_features_t& feat) +{ + presolve_budget_t b{}; + + const double nnz = std::max(feat.nnz, 1.0); + // Probing candidates are the integers of the problem the probing cache runs on. + const double n_cand = std::max(feat.n_int, 1.0); + const double n_bin = std::max(feat.n_bin, 1.0); + const double acl = std::max(feat.avg_col_len(), 1.0); + + // Rounds are uncapped. A round cap looks like a cost limit but is not one: triptim1 is + // bit-identical at 30 rounds and unlimited, while mzzv11 keeps reducing past 30 (1962 rows + // against 1576, and 0.07 error against 3.41) for 6s more. None of the Papilo blowups measured + // came from rounds, so there is nothing for the cap to save; the wall ceiling bounds the cost. + b.papilo_max_rounds = hp.presolve_max_rounds >= 0 ? (int)hp.presolve_max_rounds : -1; + + const double papilo_probe_cost = n_bin * acl; + b.papilo_max_badgesize = + hp.papilo_probing_max_badgesize >= 0 + ? (int)hp.papilo_probing_max_badgesize + : (papilo_probe_cost > papilo_badge_cost_threshold ? papilo_badge_clamped + : papilo_badge_exempt); + + // Cost of one probing sweep: every propagation touches the rows of the probed column, so the work + // a second buys falls off with problem size. nnz + n_cand * avg_col_len tracked that better than + // nnz, avg_row_len or n_cand alone over 660 measured probing runs, and dividing the scale by it + // bounds the wall time that a pure coverage target cannot: throughput ranged 1.9 to 689 work + // units per second, so the same budget was worth 360x more time on one instance than another. + // + // The ceiling is the only work bound; there is no coverage target alongside it. A coverage + // fraction cuts every instance by the same proportion whether or not it is the expensive one, + // which both obscured which of the two stopped a run and penalised the cheap instances. Dropping + // it left probing running over a second on 102 instances against 81 before, while runs over ten + // seconds fell from 34 to 11, since the ceiling truncates by cost rather than uniformly. + const double probing_cost_proxy = nnz + n_cand * acl; + b.probing_work_limit = probing_work_scale / probing_cost_proxy; + b.probing_step_size = probing_budget_step_size; + return b; +} + +// One line per presolve stage carrying the features that went in and the budgets that came out, so +// a run can be regressed offline without re-deriving anything from the solver. +inline void log_presolve_budget(const char* stage, + const presolve_features_t& f, + const presolve_budget_t& b) +{ + CUOPT_LOG_DEBUG( + "PRESOLVE_BUDGET stage=%s nvars=%.0f ncons=%.0f nnz=%.0f nint=%.0f " + "nbin=%.0f arl=%.3f acl=%.3f maxrow=%.0f density=%.3e intfrac=%.3f binfrac=%.3f " + "rounds=%d badge=%d work=%.3f step=%d", + stage, + f.n_vars, + f.n_cons, + f.nnz, + f.n_int, + f.n_bin, + f.avg_row_len(), + f.avg_col_len(), + f.max_row_len, + f.density(), + f.int_frac(), + f.bin_frac(), + b.papilo_max_rounds, + b.papilo_max_badgesize, + b.probing_work_limit, + b.probing_step_size); +} + +} // 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 fd4790479b..38d076581f 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cu +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cu @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -847,7 +848,9 @@ std::vector compute_priority_indices_by_implied_integers(problem_t bool compute_probing_cache(bound_presolve_t& bound_presolve, problem_t& problem, - timer_t timer) + timer_t timer, + double work_limit, + size_t step_size_hint) { raft::common::nvtx::range fun_scope("compute_probing_cache"); // we dont want to compute the probing cache for all variables for time and computation resources @@ -869,11 +872,14 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, std::vector>> modification_vector_pool(num_tasks); std::vector>> substitution_vector_pool(num_tasks); + std::vector iter_accum_pool(num_tasks, 0.0); + // Initialize multi_probe_presolve_pool for (size_t i = 0; i < num_tasks; i++) { multi_probe_presolve_pool.emplace_back(bound_presolve.context); multi_probe_presolve_pool[i].resize(problem); - multi_probe_presolve_pool[i].compute_stats = true; + multi_probe_presolve_pool[i].compute_stats = true; + multi_probe_presolve_pool[i].local_iter_accumulator = &iter_accum_pool[i]; } // Atomic variables for tracking progress @@ -882,18 +888,32 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, std::atomic problem_is_infeasible(false); size_t last_it_implied_singletons = 0; bool early_exit = false; - const size_t step_size = min((size_t)2048, priority_indices.size()); + const double iter_cost = probing_iter_work; + const double probe_cost = probing_probe_work; + const auto probing_t0 = std::chrono::steady_clock::now(); + double iters_done = 0.0; + size_t probes_done = 0; + double work_used = 0.0; + // Work is only folded in at the step barrier, so the step size is also the granularity at which + // the budget can be enforced: too large and a single step runs effectively unbudgeted. + const size_t step_size = min(step_size_hint, priority_indices.size()); // The pool buffers above were allocated on the main stream. // Each OMP thread below uses its own stream, so we must ensure all allocations // are visible before any per-thread kernel can reference that memory. problem.handle_ptr->sync_stream(); - CUOPT_LOG_INFO("Running probing cache with %zu tasks", num_tasks); + CUOPT_LOG_DEBUG( + "Running probing cache with %zu tasks (%zu candidate vars, work limit %.3f, step %zu)", + num_tasks, + priority_indices.size(), + work_limit, + step_size); // Main parallel loop for (size_t step_start = 0; step_start < priority_indices.size(); step_start += step_size) { if (timer.check_time_limit() || early_exit || problem_is_infeasible.load()) { break; } + if (work_used >= work_limit) { break; } size_t step_end = std::min(step_start + step_size, priority_indices.size()); #pragma omp taskloop num_tasks(num_tasks) default(shared) priority(CUOPT_DEFAULT_TASK_PRIORITY) @@ -926,8 +946,15 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, } } // implicit barrier that waits for all iterations to finish before proceeding - // TODO when we have determinism, check current threads work/time counter and filter queue - // items that are smaller or equal to that + // Single-threaded from here to the end of the step, so folding the per-task counts in a fixed + // order gives the same work_used for any thread count. + for (size_t t = 0; t < num_tasks; ++t) { + iters_done += iter_accum_pool[t]; + iter_accum_pool[t] = 0.0; + } + probes_done += step_end - step_start; + work_used = iters_done * iter_cost + (double)probes_done * probe_cost; + apply_modification_queue_to_problem(modification_vector_pool, problem); // copy host bounds again, because we changed some problem bounds raft::copy(h_var_bounds.data(), @@ -943,9 +970,28 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, } // end of step apply_substitution_queue_to_problem(substitution_vector_pool, problem); - CUOPT_LOG_DEBUG("Total number of cached probings %lu number of implied singletons %lu", - n_of_cached_probings.load(), - n_of_implied_singletons.load()); + const double probing_wall = + std::chrono::duration(std::chrono::steady_clock::now() - probing_t0).count(); + CUOPT_LOG_DEBUG( + "PRESOLVE_PROBING probes=%zu candidates=%zu iters=%.0f work=%.3f work_limit=%.3f step=%zu " + "iter_cost=%.5f probe_cost=%.5f wall=%.3f wall_limit=%.3f units_per_s=%.1f " + "budget_exhausted=%d early_exit=%d timed_out=%d cached=%lu implied_singletons=%lu", + probes_done, + priority_indices.size(), + iters_done, + work_used, + work_limit, + step_size, + iter_cost, + probe_cost, + probing_wall, + timer.get_time_limit(), + probing_wall > 0.0 ? work_used / probing_wall : 0.0, + (int)(work_used >= work_limit), + (int)early_exit, + (int)timer.check_time_limit(), + n_of_cached_probings.load(), + n_of_implied_singletons.load()); // restore the settings bound_presolve.settings = {}; return problem_is_infeasible.load(); @@ -954,7 +1000,9 @@ bool compute_probing_cache(bound_presolve_t& bound_presolve, #define INSTANTIATE(F_TYPE) \ template bool compute_probing_cache(bound_presolve_t & bound_presolve, \ problem_t & problem, \ - timer_t timer); \ + timer_t timer, \ + double work_limit, \ + size_t step_size_hint); \ template class probing_cache_t; #if MIP_INSTANTIATE_FLOAT diff --git a/cpp/src/mip_heuristics/presolve/probing_cache.cuh b/cpp/src/mip_heuristics/presolve/probing_cache.cuh index ec532febb9..24d9a9cfc1 100644 --- a/cpp/src/mip_heuristics/presolve/probing_cache.cuh +++ b/cpp/src/mip_heuristics/presolve/probing_cache.cuh @@ -11,8 +11,15 @@ #include +#include + +#include #include +#include +#include +#include + namespace cuopt::mathematical_optimization::mip { template @@ -116,9 +123,28 @@ class lb_probing_cache_t { std::unordered_map, 2>> probing_cache; }; +template +presolve_features_t probing_presolve_features(problem_t const& problem) +{ + presolve_features_t f{}; + f.n_vars = problem.n_variables; + f.n_cons = problem.n_constraints; + f.nnz = problem.nnz; + f.n_int = problem.n_integer_vars; + f.n_bin = problem.n_binary_vars; + + auto h_offsets = cuopt::host_copy(problem.offsets, problem.handle_ptr->get_stream()); + for (size_t i = 0; i + 1 < h_offsets.size(); ++i) { + f.max_row_len = std::max(f.max_row_len, h_offsets[i + 1] - h_offsets[i]); + } + return f; +} + template bool compute_probing_cache(bound_presolve_t& bound_presolve, problem_t& problem, - timer_t timer); + timer_t timer, + double work_limit = std::numeric_limits::infinity(), + size_t step_size_hint = 2048); } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp index 6a5bd341fb..e85baa4791 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.cpp @@ -49,6 +49,7 @@ #include #include +#include #include #include #include @@ -718,11 +719,13 @@ void set_presolve_options(papilo::Presolve& presolver, f_t relative_tolerance, f_t time_limit, bool dual_postsolve, - i_t num_cpu_threads) + i_t num_cpu_threads, + i_t max_rounds) { presolver.getPresolveOptions().tlim = time_limit; presolver.getPresolveOptions().threads = num_cpu_threads; // user setting or 0 (automatic) presolver.getPresolveOptions().feastol = 1e-5; + if (max_rounds > 0) { presolver.getPresolveOptions().maxrounds = max_rounds; } if (dual_postsolve) { presolver.getPresolveOptions().componentsmaxint = -1; presolver.getPresolveOptions().detectlindep = 0; @@ -735,6 +738,7 @@ void set_presolve_parameters( problem_category_t category, int nrows, int ncols, + int max_badgesize, std::optional> const& method_allowlist = std::nullopt) { // It looks like a copy. But this copy has the pointers to relevant variables in papilo @@ -746,9 +750,14 @@ void set_presolve_parameters( // Papilo has work unit measurements for probing. Because of this when the first batch fails to // produce any reductions, the algorithm stops. To avoid stopping the algorithm, we set a // minimum badge size to a huge value. The time limit makes sure that we exit if it takes too - // long + // long. + // An uncapped ncols/2 forces one probing pass to span the whole problem, so probing never + // reaches its work-based stop and runs unbounded on large MIPs whenever the clock is infinite. + // Capping the badge keeps it large enough to still find reductions while Papilo's per-badge + // working limit (~2*nnz) bounds a single pass. <=0 restores the uncapped behaviour. if (reduction_allowed("probing")) { int min_badgesize = std::max(ncols / 2, 32); + if (max_badgesize > 0) { min_badgesize = std::min(min_badgesize, max_badgesize); } params.setParameter("probing.minbadgesize", min_badgesize); } if (reduction_allowed("cliquemerging")) { @@ -836,7 +845,9 @@ third_party_presolve_status_t third_party_presolve_t::apply_papilo( f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads) + i_t num_cpu_threads, + i_t max_rounds, + i_t max_badgesize) { raft::common::nvtx::range fun_scope("Apply Papilo presolve on host"); @@ -860,12 +871,36 @@ third_party_presolve_status_t third_party_presolve_t::apply_papilo( relative_tolerance, time_limit, dual_postsolve, - num_cpu_threads); - set_presolve_parameters( - papilo_presolver, category, original_n_cons, original_n_vars, reduction_allowlist_); + num_cpu_threads, + max_rounds); + set_presolve_parameters(papilo_presolver, + category, + original_n_cons, + original_n_vars, + max_badgesize, + reduction_allowlist_); papilo_presolver.setVerbosityLevel(papilo::VerbosityLevel::kQuiet); - - auto result = papilo_presolver.apply(papilo_problem); + CUOPT_LOG_DEBUG( + "PRESOLVE_PAPILO_BUDGET rounds=%d badge_cap=%d tlim=%g", max_rounds, max_badgesize, time_limit); + + const auto papilo_t0 = std::chrono::steady_clock::now(); + auto result = papilo_presolver.apply(papilo_problem); + const double papilo_wall = + std::chrono::duration(std::chrono::steady_clock::now() - papilo_t0).count(); + // The effective badge is what set_presolve_parameters actually installed; the cap alone is + // misleading because it only binds once ncols/2 exceeds it. + int effective_badge = std::max(original_n_vars / 2, 32); + if (max_badgesize > 0) { effective_badge = std::min(effective_badge, max_badgesize); } + // hit_tlim distinguishes "presolve converged" from "presolve was cut off mid-round", which + // changes how the reduced problem below should be read. + CUOPT_LOG_DEBUG( + "PRESOLVE_PAPILO wall=%.3f tlim=%g hit_tlim=%d rounds_cap=%d badge_cap=%d badge_effective=%d", + papilo_wall, + time_limit, + (int)(papilo_wall >= 0.99 * time_limit), + max_rounds, + max_badgesize, + effective_badge); check_presolve_status(result.status); auto status = convert_papilo_presolve_status_to_third_party_presolve_status(result.status); if (result.status == papilo::PresolveStatus::kInfeasible || @@ -921,7 +956,9 @@ third_party_presolve_t::apply_presolve_from_op_problem( f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads) + i_t num_cpu_threads, + i_t max_rounds, + i_t max_badgesize) { auto* handle = op_problem.get_handle_ptr(); @@ -941,7 +978,9 @@ third_party_presolve_t::apply_presolve_from_op_problem( absolute_tolerance, relative_tolerance, time_limit, - num_cpu_threads); + num_cpu_threads, + max_rounds, + max_badgesize); // On terminal statuses the mps entry returns an empty reduced problem; // mirror that shape on the device side without going through H->D. @@ -981,7 +1020,9 @@ third_party_presolve_t::apply_presolve_from_mps_data( f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads) + i_t num_cpu_threads, + i_t max_rounds, + i_t max_badgesize) { presolver_ = presolver; maximize_ = mps.get_sense(); @@ -1031,7 +1072,9 @@ third_party_presolve_t::apply_presolve_from_mps_data( absolute_tolerance, relative_tolerance, time_limit, - num_cpu_threads); + num_cpu_threads, + max_rounds, + max_badgesize); if (status == third_party_presolve_status_t::INFEASIBLE || status == third_party_presolve_status_t::UNBOUNDED || @@ -1094,9 +1137,12 @@ third_party_presolve_status_t third_party_presolve_t::apply_to_subprob settings.dual_tol, time_limit, dual_postsolve, - num_threads); + num_threads, + -1); + // Node presolve already runs under a finite time limit, so it keeps the unbounded round count and + // uncapped badge; the budgets apply to root presolve only. set_presolve_parameters( - papilo_presolver, problem_category_t::MIP, orig_rows, orig_cols, reduction_allowlist_); + papilo_presolver, problem_category_t::MIP, orig_rows, orig_cols, -1, reduction_allowlist_); // Disable papilo logs papilo_presolver.setVerbosityLevel(papilo::VerbosityLevel::kQuiet); @@ -1580,16 +1626,42 @@ void papilo_postsolve_deleter::operator()(papilo::PostsolveStorage* pt delete ptr; } +template +presolve_features_t papilo_presolve_features(optimization_problem_t const& op_problem) +{ + presolve_features_t f{}; + f.n_vars = op_problem.get_n_variables(); + f.n_cons = op_problem.get_n_constraints(); + f.nnz = op_problem.get_nnz(); + + const auto var_types = op_problem.get_variable_types_host(); + const auto lower = op_problem.get_variable_lower_bounds_host(); + const auto upper = op_problem.get_variable_upper_bounds_host(); + for (size_t j = 0; j < var_types.size(); ++j) { + if (var_types[j] != var_t::INTEGER) { continue; } + f.n_int += 1.0; + if (lower[j] >= 0.0 && upper[j] <= 1.0) { f.n_bin += 1.0; } + } + + const auto offsets = op_problem.get_constraint_matrix_offsets_host(); + for (size_t i = 0; i + 1 < offsets.size(); ++i) { + f.max_row_len = std::max(f.max_row_len, offsets[i + 1] - offsets[i]); + } + return f; +} + #if MIP_INSTANTIATE_FLOAT || PDLP_INSTANTIATE_FLOAT template struct papilo_postsolve_deleter; template class third_party_presolve_t; template void papilo_round_trip(simplex::user_problem_t&); +template presolve_features_t papilo_presolve_features(optimization_problem_t const&); #endif #if MIP_INSTANTIATE_DOUBLE template struct papilo_postsolve_deleter; template class third_party_presolve_t; template void papilo_round_trip(simplex::user_problem_t&); +template presolve_features_t papilo_presolve_features(optimization_problem_t const&); #endif } // namespace cuopt::mathematical_optimization::mip diff --git a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp index 5eec96221d..60f4f7fc6d 100644 --- a/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp +++ b/cpp/src/mip_heuristics/presolve/third_party_presolve.hpp @@ -17,6 +17,7 @@ #include #include #include +#include #include @@ -44,6 +45,11 @@ enum class third_party_presolve_status_t { UNCHANGED, }; +// Features of the problem as the user handed it in, i.e. before any reduction. This is what Papilo +// itself will work on, so its budget is derived from these rather than from the reduced problem. +template +presolve_features_t papilo_presolve_features(optimization_problem_t const& op_problem); + template struct third_party_presolve_result_t { third_party_presolve_status_t status; @@ -84,7 +90,9 @@ class third_party_presolve_t { f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads = 0); + i_t num_cpu_threads = 0, + i_t max_rounds = -1, + i_t max_badgesize = -1); // Host entry: takes an mps_data_model_t and returns a host-side reduced // mps_data_model_t. Pure-host throughout @@ -96,7 +104,9 @@ class third_party_presolve_t { f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads = 0); + i_t num_cpu_threads = 0, + i_t max_rounds = -1, + i_t max_badgesize = -1); // If set, only Papilo methods whose getName() is listed are registered void set_reduction_allowlist(std::optional> allowlist) @@ -172,7 +182,9 @@ class third_party_presolve_t { f_t absolute_tolerance, f_t relative_tolerance, double time_limit, - i_t num_cpu_threads); + i_t num_cpu_threads, + i_t max_rounds, + i_t max_badgesize); // Host-only per-backend postsolve helpers. Both resize their vector args // to original-problem dimensions. diff --git a/cpp/src/mip_heuristics/solve.cu b/cpp/src/mip_heuristics/solve.cu index b970eb1bf8..f55aca6878 100644 --- a/cpp/src/mip_heuristics/solve.cu +++ b/cpp/src/mip_heuristics/solve.cu @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -560,14 +561,15 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p auto constexpr const dual_postsolve = false; if (run_presolve) { sort_csr(op_problem); - // allocate not more than 10% of the time limit to presolve. - // Note that this is not the presolve time, but the time limit for presolve. - const auto& hp = settings.heuristic_params; - double presolve_time_limit = - std::min(hp.presolve_time_ratio * time_limit, hp.presolve_max_time); - if (settings.determinism_mode == CUOPT_MODE_DETERMINISTIC) { - presolve_time_limit = std::numeric_limits::infinity(); - } + const auto& hp = settings.heuristic_params; + const auto papilo_features = mip::papilo_presolve_features(op_problem); + const auto papilo_budget = mip::evaluate_presolve_budget(hp, papilo_features); + mip::log_presolve_budget("PAPILO", papilo_features, papilo_budget); + + const double presolve_time_limit = settings.determinism_mode == CUOPT_MODE_DETERMINISTIC + ? std::numeric_limits::infinity() + : timer.remaining_time(); + presolver = std::make_unique>(); auto result = presolver->apply_presolve_from_op_problem( op_problem, @@ -577,7 +579,9 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p settings.tolerances.absolute_tolerance, settings.tolerances.relative_tolerance, presolve_time_limit, - settings.num_cpu_threads); + settings.num_cpu_threads, + papilo_budget.papilo_max_rounds, + papilo_budget.papilo_max_badgesize); if (result.status == mip::third_party_presolve_status_t::INFEASIBLE) { return mip_solution_t(mip_termination_status_t::Infeasible, @@ -607,6 +611,19 @@ mip_solution_t solve_mip_helper(optimization_problem_t& op_p CUOPT_LOG_INFO("%d implied integers", presolve_result_opt->implied_integer_indices.size()); } CUOPT_LOG_INFO("Papilo presolve time: %.2f", presolve_time); + // What the round cap actually bought, logged here rather than inferred from the probing stage + // so it is still recorded when the run never gets that far. + CUOPT_LOG_DEBUG( + "PRESOLVE_PAPILO_REDUCED nvars=%d ncons=%d nnz=%d nint=%d nbin=%d from_nvars=%.0f " + "from_ncons=%.0f from_nnz=%.0f", + problem.n_variables, + problem.n_constraints, + problem.nnz, + problem.n_integer_vars, + problem.n_binary_vars, + papilo_features.n_vars, + papilo_features.n_cons, + papilo_features.nnz); if (result.status == mip::third_party_presolve_status_t::OPTIMAL) { CUOPT_LOG_INFO("Optimal solution found during presolve."); diff --git a/cpp/src/mip_heuristics/solver.cu b/cpp/src/mip_heuristics/solver.cu index b045190e9f..f8eac0c4d8 100644 --- a/cpp/src/mip_heuristics/solver.cu +++ b/cpp/src/mip_heuristics/solver.cu @@ -207,16 +207,13 @@ solution_t mip_solver_t::run_solver() return sol; } - dm.timer = timer_; - const bool run_presolve = context.settings.presolver != presolver_t::None; - f_t time_limit = context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC - ? std::numeric_limits::infinity() - : timer_.remaining_time(); - const auto& hp = context.settings.heuristic_params; - double presolve_time_limit = std::min(hp.presolve_time_ratio * time_limit, hp.presolve_max_time); - presolve_time_limit = context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC - ? std::numeric_limits::infinity() - : presolve_time_limit; + dm.timer = timer_; + const bool run_presolve = context.settings.presolver != presolver_t::None; + // cuOpt presolve no longer gets a share of the solve. Its cost is bounded by the probing work + // ceiling in presolve_budget_policy.hpp, so this is only the end of the solve. + const f_t presolve_time_limit = context.settings.determinism_mode == CUOPT_MODE_DETERMINISTIC + ? std::numeric_limits::infinity() + : timer_.remaining_time(); if (std::isfinite(presolve_time_limit)) CUOPT_LOG_DEBUG("Presolve time limit: %g", presolve_time_limit); bool presolve_success = run_presolve ? dm.run_presolve(presolve_time_limit, timer_) : true; diff --git a/cpp/tests/linear_programming/grpc/grpc_client_test.cpp b/cpp/tests/linear_programming/grpc/grpc_client_test.cpp index b892e5d7ae..f8fed6eee3 100644 --- a/cpp/tests/linear_programming/grpc/grpc_client_test.cpp +++ b/cpp/tests/linear_programming/grpc/grpc_client_test.cpp @@ -1891,8 +1891,8 @@ TEST(MapperRoundtrip, MIPSettingsAllFields) // mapping line would produce a default-valued mismatch on decode. orig.heuristic_params.population_size = 64; // default 32 orig.heuristic_params.num_cpufj_threads = 4; // default 8 - orig.heuristic_params.presolve_time_ratio = 0.2; // default 0.1 - orig.heuristic_params.presolve_max_time = 45.0; // default 60.0 + orig.heuristic_params.presolve_max_rounds = 12; // default -1 + orig.heuristic_params.papilo_probing_max_badgesize = 64; // default -1 orig.heuristic_params.root_lp_time_ratio = 0.25; // default 0.1 orig.heuristic_params.root_lp_max_time = 7.5; // default 15.0 orig.heuristic_params.rins_time_limit = 4.0; // default 3.0 @@ -1961,8 +1961,8 @@ TEST(MapperRoundtrip, MIPSettingsAllFields) // Heuristic hyper-parameters EXPECT_EQ(restored.heuristic_params.population_size, 64); EXPECT_EQ(restored.heuristic_params.num_cpufj_threads, 4); - EXPECT_DOUBLE_EQ(restored.heuristic_params.presolve_time_ratio, 0.2); - EXPECT_DOUBLE_EQ(restored.heuristic_params.presolve_max_time, 45.0); + EXPECT_EQ(restored.heuristic_params.presolve_max_rounds, 12); + EXPECT_EQ(restored.heuristic_params.papilo_probing_max_badgesize, 64); EXPECT_DOUBLE_EQ(restored.heuristic_params.root_lp_time_ratio, 0.25); EXPECT_DOUBLE_EQ(restored.heuristic_params.root_lp_max_time, 7.5); EXPECT_DOUBLE_EQ(restored.heuristic_params.rins_time_limit, 4.0); @@ -2496,10 +2496,13 @@ TEST(MapperRoundtrip, MIPSettingsDefaultProtoPreservesAllCppDefaults) // heuristic_params: spot-check one of each kind (int, double). EXPECT_EQ(after.heuristic_params.population_size, fresh.heuristic_params.population_size); EXPECT_EQ(after.heuristic_params.num_cpufj_threads, fresh.heuristic_params.num_cpufj_threads); - EXPECT_DOUBLE_EQ(after.heuristic_params.presolve_time_ratio, - fresh.heuristic_params.presolve_time_ratio); - EXPECT_DOUBLE_EQ(after.heuristic_params.presolve_max_time, - fresh.heuristic_params.presolve_max_time); + EXPECT_EQ(after.heuristic_params.presolve_max_rounds, fresh.heuristic_params.presolve_max_rounds); + EXPECT_EQ(after.heuristic_params.papilo_probing_max_badgesize, + fresh.heuristic_params.papilo_probing_max_badgesize); + EXPECT_DOUBLE_EQ(after.heuristic_params.root_lp_time_ratio, + fresh.heuristic_params.root_lp_time_ratio); + EXPECT_DOUBLE_EQ(after.heuristic_params.root_lp_max_time, + fresh.heuristic_params.root_lp_max_time); EXPECT_DOUBLE_EQ(after.heuristic_params.rins_fix_rate, fresh.heuristic_params.rins_fix_rate); EXPECT_EQ(after.heuristic_params.enabled_recombiners, fresh.heuristic_params.enabled_recombiners); EXPECT_DOUBLE_EQ(after.heuristic_params.initial_infeasibility_weight, diff --git a/cpp/tests/mip/heuristics_hyper_params_test.cu b/cpp/tests/mip/heuristics_hyper_params_test.cu index c3ec4371f0..f02a5c6154 100644 --- a/cpp/tests/mip/heuristics_hyper_params_test.cu +++ b/cpp/tests/mip/heuristics_hyper_params_test.cu @@ -59,8 +59,6 @@ TEST_F(HeuristicsHyperParamsTest, CustomValuesRoundTrip) std::ofstream f(tmp_path); f << "mip_hyper_heuristic_population_size = 64\n"; f << "mip_hyper_heuristic_num_cpufj_threads = 4\n"; - f << "mip_hyper_heuristic_presolve_time_ratio = 0.2\n"; - f << "mip_hyper_heuristic_presolve_max_time = 120\n"; f << "mip_hyper_heuristic_root_lp_time_ratio = 0.05\n"; f << "mip_hyper_heuristic_root_lp_max_time = 30\n"; f << "mip_hyper_heuristic_rins_time_limit = 5\n"; @@ -82,8 +80,6 @@ TEST_F(HeuristicsHyperParamsTest, CustomValuesRoundTrip) EXPECT_EQ(hp.population_size, 64); EXPECT_EQ(hp.num_cpufj_threads, 4); - EXPECT_DOUBLE_EQ(hp.presolve_time_ratio, 0.2); - EXPECT_DOUBLE_EQ(hp.presolve_max_time, 120.0); EXPECT_DOUBLE_EQ(hp.root_lp_time_ratio, 0.05); EXPECT_DOUBLE_EQ(hp.root_lp_max_time, 30.0); EXPECT_DOUBLE_EQ(hp.rins_time_limit, 5.0); @@ -116,7 +112,7 @@ TEST_F(HeuristicsHyperParamsTest, PartialConfigKeepsDefaults) mip_heuristics_hyper_params_t defaults; EXPECT_EQ(hp.num_cpufj_threads, defaults.num_cpufj_threads); - EXPECT_DOUBLE_EQ(hp.presolve_time_ratio, defaults.presolve_time_ratio); + EXPECT_DOUBLE_EQ(hp.root_lp_time_ratio, defaults.root_lp_time_ratio); EXPECT_EQ(hp.n_of_minimums_for_exit, defaults.n_of_minimums_for_exit); EXPECT_EQ(hp.enabled_recombiners, defaults.enabled_recombiners); } diff --git a/skills/cuopt-developer/SKILL.md b/skills/cuopt-developer/SKILL.md index c856538856..aa488e064b 100644 --- a/skills/cuopt-developer/SKILL.md +++ b/skills/cuopt-developer/SKILL.md @@ -265,6 +265,14 @@ When implementing or debugging **VRP dimensions** (constraints, objectives, forw Read it **before** adding a new dimension or changing combine semantics. +## Budgeting a solver stage and attributing regressions + +When adding or tuning a **limit on a stage that shares the solve's time budget** (presolve, probing, cut generation, heuristics), or when a benchmark regression looks like it came from one, read: + +- **`references/stage_budgets.md`** — why a deterministic work counter does not bound wall time, how to size a limit so the stage concludes before its ceiling, and the ordered checks that attribute a regression to a budget before tuning its value. + +Run the attribution checks **before** changing a limit's value — the common failure is tuning a limit that was never what bound. + ## Numerical issues in non-routing solver internals When a bug surfaces as **wrong-but-plausible** solver output (invalid lower bound, unexpectedly large duals, 10× iteration blow-up after a small change) rather than a crash, read: diff --git a/skills/cuopt-developer/references/stage_budgets.md b/skills/cuopt-developer/references/stage_budgets.md new file mode 100644 index 0000000000..be0444998b --- /dev/null +++ b/skills/cuopt-developer/references/stage_budgets.md @@ -0,0 +1,95 @@ +# Budgeting a solver stage, and attributing a benchmark regression to one + +Applies to any bounded stage that consumes a shared time budget — presolve, probing, cut +generation, root LP, heuristics. + +## A work counter buys reproducibility, not a time bound + +A deterministic work counter (units of effort charged by a cost model) makes a stage's +*coverage* machine-independent. It does not bound how long that coverage takes, because the +work-per-second a stage achieves varies with problem structure. Measure the spread before +assuming otherwise: log realised `work / wall` per run and look at the range. When it spans +orders of magnitude, a single work limit is worth wildly different amounts of time per +instance, and the slow end will consume the whole solve. + +Two limits, two jobs — keep both: + +- **Work limit** — makes coverage reproducible across machines. +- **Wall ceiling** — keeps the stage from starving whatever runs after it. + +## Size the limit to conclude *before* the ceiling + +A stage that hits its wall ceiling is the worst case, not the safe one: the effort is spent +and the partial result is discarded. Prefer a limit the stage finishes inside, leaving the +ceiling as a backstop that never fires. Verify by checking that the stage's `hit_limit` / +`timed_out` flag is clear on the instances that used to be truncated. + +To bound wall time structurally, divide a scale constant by a **cost proxy** built from +dimensions the stage's inner loop actually touches. Rank candidate proxies by how nearly +`throughput * proxy` is constant across measured runs, and pick the scale so the slowest +measured run still finishes with margin. The proxy will be too noisy to *predict* time; it +only needs to *bound* it, so tune it against the worst case and accept that typical instances +get less than they could use. + +## Verify which knob costs, rather than the one that looks like it should + +Iteration or round counts read like cost limits and often are not: a stage that converges +before its round cap is not bounded by it at all. The knob that drives cost is usually the one +controlling work *per* round — batch or candidate-set size — and it may sit behind a +third-party default. + +Establish whether a cap binds by **running the stage with it removed and diffing the output**, +not by reading a flag. Flags answer narrower questions than they appear to: a `hit_tlim` / +`timed_out` field reports the *time* limit only, so it stays clear on a stage the round cap +stopped early, and reading it as "no limit bound" silently exonerates the wrong knob. Unless a +counter records the cap's own trigger, the A/B is the only sound check. + +When a round cap does bind, prefer deleting it over tuning it. It is a poor cost limit in both +directions — where it binds it truncates a stage mid-convergence and costs output, and where it +does not it saves nothing — while the wall ceiling bounds cost directly and only fires when +time is genuinely short. + +A knob can also be free on some instances and a genuine quality lever on others. Sweep it and +record both cost and the stage's own output (reduced dimensions, cuts kept) — identical output +at lower cost means the knob is free; changed output means there is a real trade-off, which +must then be settled end-to-end rather than on the stage's own metric. + +## More effort in a stage can be worse end-to-end + +A stage's own metric improving is not evidence the solve improves. Effort spent there is taken +from everything downstream, so compare final objective and gap, not reduction counts. Watch +for a stage consuming a large share of the budget alongside a missing dual bound (infinite +gap): that pairing means the stage starved the root relaxation. + +## Attributing a regression before tuning + +Run these checks in order; each can end the investigation: + +0. **Is the regression larger than the instance's own noise?** Use the spread between repeats + of the *same* build as a per-instance noise band and drop everything inside it. This is not + a formality: on one 240-instance run it removed 18 of 26 apparent regressions, and four of + the remaining eight turned out to have a bit-identical reduced problem, leaving four real + ones. Tuning against noise is worse than not tuning, because the change looks justified. + Instances with wide bands cannot be settled by single runs at all — carry them to the full + set as competing configs rather than picking a winner from one measurement. +1. **Did the limit bind?** Split instances by the `budget_exhausted` flag and compare the mean + error delta per group. If the regressed set is not the bound set, the limit is not the + cause. +2. **Did the stage starve a later one?** Correlate stage wall time as a fraction of the budget + against instances that lost their dual bound. +3. **Control: remove the limit on the failing instance.** If the failure reproduces with the + stage fully unbounded, the limit is exonerated — look at whichever stage the time actually + went to. This is the cheapest way to avoid tuning a limit that was never responsible. + +Expect a binding limit to be **bidirectional**: truncating a stage wins on instances where its +effort was wasted and loses where it was load-bearing. A near-zero aggregate delta can hide +large per-instance swings in both directions, so judge by the split, never by the mean alone. + +## Log one parseable line per stage + +Emit the features the budget was derived from, the budget that came out, and what was actually +spent, on a single greppable line per stage. That makes a sweep regressable offline without +re-deriving anything from the solver, and it is what makes the attribution checks above +one-liners instead of re-runs. Include whether the limit bound and which of several limits +bound, since "the budget was 300 and it spent 302" is the fact that distinguishes a limit that +shaped the stage from one that stopped it.