From 2e9de8d3c282b61810c1518afe45a0f2cefcdea1 Mon Sep 17 00:00:00 2001 From: Trevor McKay Date: Fri, 10 Jul 2026 09:34:25 -0400 Subject: [PATCH 01/17] add vrp support to the grpc server --- cpp/CMakeLists.txt | 21 +- .../cuopt/routing/cpu_routing_problem.hpp | 127 +++++++ cpp/src/grpc/codegen/field_registry.yaml | 1 + .../codegen/generated/cuopt_remote_data.proto | 4 + .../generated_enum_converters_problem.inc | 4 + cpp/src/grpc/cuopt_remote.proto | 7 +- cpp/src/grpc/cuopt_remote_service.proto | 5 +- cpp/src/grpc/cuopt_routing.proto | 168 +++++++++ cpp/src/grpc/grpc_routing_problem_mapper.cpp | 335 ++++++++++++++++++ cpp/src/grpc/grpc_routing_problem_mapper.hpp | 36 ++ cpp/src/grpc/server/grpc_service_impl.cpp | 21 +- cpp/src/grpc/server/grpc_worker.cpp | 91 ++++- cpp/src/routing/CMakeLists.txt | 3 +- cpp/src/routing/cpu_routing_problem.cu | 305 ++++++++++++++++ cpp/tests/routing/CMakeLists.txt | 6 + cpp/tests/routing/grpc/CMakeLists.txt | 56 +++ .../routing/grpc/grpc_vrp_test_driver.cpp | 298 ++++++++++++++++ 17 files changed, 1467 insertions(+), 21 deletions(-) create mode 100644 cpp/include/cuopt/routing/cpu_routing_problem.hpp create mode 100644 cpp/src/grpc/cuopt_routing.proto create mode 100644 cpp/src/grpc/grpc_routing_problem_mapper.cpp create mode 100644 cpp/src/grpc/grpc_routing_problem_mapper.hpp create mode 100644 cpp/src/routing/cpu_routing_problem.cu create mode 100644 cpp/tests/routing/grpc/CMakeLists.txt create mode 100644 cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 61f6eb91df..65e0de2d5e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -429,6 +429,23 @@ if (NOT SKIP_GRPC_BUILD) set(GRPC_SERVICE_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_remote_service.grpc.pb.cc") set(GRPC_SERVICE_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_remote_service.grpc.pb.h") + # Routing proto (standalone VRP messages; imported by service proto) + set(ROUTING_PROTO_FILE "${PROTO_PATH_MANUAL}/cuopt_routing.proto") + set(ROUTING_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing.pb.cc") + set(ROUTING_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing.pb.h") + + add_custom_command( + OUTPUT "${ROUTING_PROTO_SRCS}" "${ROUTING_PROTO_HDRS}" + COMMAND ${_PROTOBUF_PROTOC} + ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} + --proto_path ${PROTO_PATH_MANUAL} + --proto_path ${PROTO_PATH_GEN} + ${ROUTING_PROTO_FILE} + DEPENDS ${ROUTING_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} + COMMENT "Generating C++ code from cuopt_routing.proto" + VERBATIM + ) + add_custom_command( OUTPUT "${GRPC_PROTO_SRCS}" "${GRPC_PROTO_HDRS}" "${GRPC_SERVICE_SRCS}" "${GRPC_SERVICE_HDRS}" COMMAND ${_PROTOBUF_PROTOC} @@ -438,7 +455,7 @@ if (NOT SKIP_GRPC_BUILD) --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} ${GRPC_PROTO_FILE} - DEPENDS ${GRPC_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} + DEPENDS ${GRPC_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_PROTO_FILE} COMMENT "Generating gRPC C++ code from cuopt_remote_service.proto" VERBATIM ) @@ -490,12 +507,14 @@ if (NOT SKIP_GRPC_BUILD) set(GRPC_INFRA_FILES ${DATA_PROTO_SRCS} ${PROTO_SRCS} + ${ROUTING_PROTO_SRCS} ${GRPC_PROTO_SRCS} ${GRPC_SERVICE_SRCS} src/grpc/grpc_problem_mapper.cpp src/grpc/grpc_solution_mapper.cpp src/grpc/grpc_settings_mapper.cpp src/grpc/grpc_service_mapper.cpp + src/grpc/grpc_routing_problem_mapper.cpp src/grpc/client/grpc_client.cpp src/grpc/client/grpc_client_env.cpp src/grpc/client/cython_grpc_client.cpp diff --git a/cpp/include/cuopt/routing/cpu_routing_problem.hpp b/cpp/include/cuopt/routing/cpu_routing_problem.hpp new file mode 100644 index 0000000000..6e4aa0f4cd --- /dev/null +++ b/cpp/include/cuopt/routing/cpu_routing_problem.hpp @@ -0,0 +1,127 @@ +/* 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 + +namespace raft { +class handle_t; +} + +namespace cuopt { +namespace routing { + +/** + * @brief Host-memory owning routing problem (gRPC / remote-execution analog of + * data_model_view_t). Owns all arrays in std::vector and can materialize a + * device-backed data_model_view_t via to_device(). + */ +struct cpu_cost_matrix_t { + uint8_t vehicle_type = 0; + std::vector matrix; // num_locations x num_locations, row-major +}; + +struct cpu_capacity_dimension_t { + std::string name; + std::vector demand; // per-order + std::vector capacity; // per-vehicle +}; + +struct cpu_vehicle_break_t { + int32_t earliest = 0; + int32_t latest = 0; + int32_t duration = 0; + std::vector locations; +}; + +struct cpu_uniform_break_t { + std::vector earliest; + std::vector latest; + std::vector duration; +}; + +struct cpu_initial_solution_t { + std::vector vehicle_ids; + std::vector routes; + std::vector types; // node_type_t values + std::vector sol_offsets; +}; + +class cpu_routing_problem_t { + public: + int32_t num_locations = 0; + int32_t fleet_size = 0; + int32_t num_orders = -1; // -1 => same as num_locations + + std::vector cost_matrices; + std::vector transit_time_matrices; + + std::vector vehicle_start_locations; + std::vector vehicle_return_locations; + std::vector vehicle_tw_earliest; + std::vector vehicle_tw_latest; + std::vector vehicle_types; + std::vector drop_return_trips; // 0/1 (avoid vector) + std::vector skip_first_trips; // 0/1 + std::vector vehicle_max_costs; + std::vector vehicle_max_times; + std::vector vehicle_fixed_costs; + + std::vector order_locations; + std::vector order_tw_earliest; + std::vector order_tw_latest; + std::vector order_prizes; + // vehicle_id -> service times; use -1 for the default (all vehicles) + std::map> order_service_times; + + std::vector pickup_indices; + std::vector delivery_indices; + + std::vector capacity_dimensions; + + std::vector break_locations; + std::vector uniform_breaks; + std::map> vehicle_breaks; + + std::map> vehicle_order_match; + std::map> order_vehicle_match; + std::map> order_precedence; + + std::vector objectives; // objective_t enum values + std::vector objective_weights; + int32_t min_vehicles = 0; + + cpu_initial_solution_t initial_solutions; + + /** Opaque owner of device buffers backing the returned data_model_view_t. */ + struct device_data_t; + + /** Deleter so unique_ptr can destroy incomplete device_data_t outside the .cu TU. */ + struct device_data_deleter { + void operator()(device_data_t* p) const; + }; + + using device_data_ptr = std::unique_ptr; + + /** + * @brief Copy host data to the GPU and return a non-owning data_model_view_t. + * The returned device_data_t must outlive the view. + */ + std::pair, device_data_ptr> to_device(raft::handle_t* handle) const; +}; + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 1981af5091..9567992275 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -168,6 +168,7 @@ enums: values: - LP - MIP + - VRP # ResultFieldId is auto-derived from solution/warm-start array_id fields. # Aliases here preserve backward compatibility with upstream's abbreviated diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 6c3931664e..0b343908e4 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -51,6 +51,7 @@ enum VariableType { enum ProblemCategory { LP = 0; MIP = 1; + VRP = 2; } enum ResultFieldId { @@ -343,4 +344,7 @@ message ChunkedResultHeader { double ws_last_restart_kkt_score = 3005; double ws_sum_solution_weight = 3006; int32 ws_iterations_since_last_restart = 3007; + // VRP (manual extension; not yet driven by field_registry.yaml) + bool is_vrp = 4000; + bytes routing_solution = 4001; // serialized RoutingSolution proto } diff --git a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc index 9ffc5fbf19..8675483756 100644 --- a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc +++ b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc @@ -27,6 +27,7 @@ cuopt::remote::ProblemCategory to_proto_problem_category(problem_category_t v) switch (v) { case problem_category_t::LP: return cuopt::remote::LP; case problem_category_t::MIP: return cuopt::remote::MIP; + case problem_category_t::IP: return cuopt::remote::MIP; } throw std::invalid_argument("Unknown problem_category_t: " + std::to_string(static_cast(v))); } @@ -36,6 +37,9 @@ problem_category_t from_proto_problem_category(cuopt::remote::ProblemCategory v) switch (v) { case cuopt::remote::LP: return problem_category_t::LP; case cuopt::remote::MIP: return problem_category_t::MIP; + case cuopt::remote::VRP: + throw std::invalid_argument( + "VRP is not a mathematical_optimization::problem_category_t"); } throw std::invalid_argument("Unknown cuopt::remote::ProblemCategory: " + std::to_string(static_cast(v))); } diff --git a/cpp/src/grpc/cuopt_remote.proto b/cpp/src/grpc/cuopt_remote.proto index c14298248f..ebd1852c9c 100644 --- a/cpp/src/grpc/cuopt_remote.proto +++ b/cpp/src/grpc/cuopt_remote.proto @@ -13,7 +13,7 @@ import public "cuopt_remote_data.proto"; // Protocol version and metadata message RequestHeader { uint32 version = 1; // Protocol version (currently 1) - ProblemCategory problem_category = 2; // LP or MIP + ProblemCategory problem_category = 2; // LP, MIP, or VRP } // LP solve request @@ -58,6 +58,10 @@ message StatusResponse { } // Response for get result +// Note: RoutingSolution lives in cuopt_routing.proto. To avoid a circular +// import (cuopt_routing.proto imports this file for RequestHeader), the VRP +// solution is carried as a serialized RoutingSolution blob here. Callers +// parse it with RoutingSolution::ParseFromString. message ResultResponse { ResponseStatus status = 1; string error_message = 2; @@ -65,6 +69,7 @@ message ResultResponse { oneof solution { LPSolution lp_solution = 10; MIPSolution mip_solution = 11; + bytes routing_solution = 12; // serialized cuopt.remote.RoutingSolution } } diff --git a/cpp/src/grpc/cuopt_remote_service.proto b/cpp/src/grpc/cuopt_remote_service.proto index d8d617af2f..bdb8d37bbb 100644 --- a/cpp/src/grpc/cuopt_remote_service.proto +++ b/cpp/src/grpc/cuopt_remote_service.proto @@ -7,6 +7,7 @@ package cuopt.remote; // Import the existing message definitions import "cuopt_remote.proto"; +import "cuopt_routing.proto"; // ============================================================================= // gRPC Service Definition @@ -17,10 +18,11 @@ service CuOptRemoteService { // Async Job Management // ------------------------- - // Submit a new LP or MIP solve job (returns immediately with job_id). + // Submit a new LP, MIP, or VRP solve job (returns immediately with job_id). // Used for problems whose serialized SubmitJobRequest fits within the // gRPC max message size (256 MiB default, configurable per-side). // Larger problems use the chunked upload path below; see ArrayChunk. + // VRP is unary-only in this POC (no chunked VRP upload yet). rpc SubmitJob(SubmitJobRequest) returns (SubmitJobResponse); // ------------------------- @@ -98,6 +100,7 @@ message SubmitJobRequest { oneof job_data { SolveLPRequest lp_request = 1; SolveMIPRequest mip_request = 2; + SolveVRPRequest vrp_request = 3; } } diff --git a/cpp/src/grpc/cuopt_routing.proto b/cpp/src/grpc/cuopt_routing.proto new file mode 100644 index 0000000000..de1d399f18 --- /dev/null +++ b/cpp/src/grpc/cuopt_routing.proto @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package cuopt.remote; + +import "cuopt_remote.proto"; // RequestHeader, ProblemCategory + +// --- Sub-messages --- + +message CostMatrix { + uint32 vehicle_type = 1; + repeated float values = 2; // row-major, n_locations x n_locations +} + +message CapacityDimension { + string name = 1; + repeated int32 demand = 2; // per-order + repeated int32 capacity = 3; // per-vehicle +} + +message VehicleBreak { + int32 earliest = 1; + int32 latest = 2; + int32 duration = 3; + repeated int32 locations = 4; +} + +message PerVehicleBreaks { + int32 vehicle_id = 1; + repeated VehicleBreak breaks = 2; +} + +message UniformBreakDimension { + repeated int32 earliest = 1; + repeated int32 latest = 2; + repeated int32 duration = 3; +} + +message MatchEntry { + int32 id = 1; + repeated int32 matches = 2; +} + +message PrecedenceEntry { + int32 order_id = 1; + repeated int32 preceding_orders = 2; +} + +message ServiceTimeEntry { + int32 vehicle_id = 1; // -1 for default + repeated int32 service_times = 2; +} + +message RoutingObjectiveFunction { + repeated int32 objectives = 1; // objective_t enum values + repeated float weights = 2; +} + +message InitialSolution { + repeated int32 vehicle_ids = 1; + repeated int32 routes = 2; + repeated int32 types = 3; // node_type_t values (0-3) + repeated int32 sol_offsets = 4; +} + +// --- Main problem --- + +message RoutingProblem { + int32 num_locations = 1; + int32 fleet_size = 2; + int32 num_orders = 3; + + repeated CostMatrix cost_matrices = 10; + repeated CostMatrix transit_time_matrices = 11; + + // Vehicle arrays (size = fleet_size) + repeated int32 vehicle_start_locations = 20; + repeated int32 vehicle_return_locations = 21; + repeated int32 vehicle_tw_earliest = 22; + repeated int32 vehicle_tw_latest = 23; + repeated uint32 vehicle_types = 24; + repeated bool drop_return_trips = 25; + repeated bool skip_first_trips = 26; + repeated float vehicle_max_costs = 27; + repeated float vehicle_max_times = 28; + repeated float vehicle_fixed_costs = 29; + + // Order arrays (size = num_orders) + repeated int32 order_locations = 30; + repeated int32 order_tw_earliest = 31; + repeated int32 order_tw_latest = 32; + repeated float order_prizes = 33; + repeated ServiceTimeEntry order_service_times = 34; + + // PDP + repeated int32 pickup_indices = 40; + repeated int32 delivery_indices = 41; + + // Capacity + repeated CapacityDimension capacity_dimensions = 50; + + // Breaks + repeated int32 break_locations = 60; + repeated UniformBreakDimension uniform_breaks = 61; + repeated PerVehicleBreaks vehicle_breaks = 62; + + // Matching + repeated MatchEntry vehicle_order_match = 70; + repeated MatchEntry order_vehicle_match = 71; + + // Precedence + repeated PrecedenceEntry order_precedence = 80; + + // Objective + RoutingObjectiveFunction objective = 90; + int32 min_vehicles = 91; + + // Warm start + InitialSolution initial_solutions = 100; +} + +// --- Settings --- + +message RoutingSolverSettings { + float time_limit = 1; + bool verbose = 2; + bool error_logging = 3; + string dump_best_results_path = 10; + int32 dump_best_results_interval = 11; +} + +// --- Request --- + +message SolveVRPRequest { + RequestHeader header = 1; + RoutingProblem problem = 2; + RoutingSolverSettings settings = 3; +} + +// --- Response --- + +enum RoutingSolutionStatus { + ROUTING_SUCCESS = 0; + ROUTING_INFEASIBLE = 1; + ROUTING_TIMEOUT = 2; + ROUTING_EMPTY = 3; + ROUTING_ERROR = 4; +} + +message RoutingSolution { + repeated int32 route = 1; + repeated double arrival_stamp = 2; + repeated int32 truck_id = 3; + repeated int32 locations = 4; + repeated int32 node_types = 5; + repeated int32 unserviced_nodes = 6; + repeated int32 accepted = 7; + + int32 vehicle_count = 10; + double total_objective_value = 11; + map objective_values = 12; + + RoutingSolutionStatus status = 20; + string status_message = 21; + string error_message = 22; +} diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/grpc_routing_problem_mapper.cpp new file mode 100644 index 0000000000..e758588f8e --- /dev/null +++ b/cpp/src/grpc/grpc_routing_problem_mapper.cpp @@ -0,0 +1,335 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "grpc_routing_problem_mapper.hpp" + +#include +#include + +namespace cuopt { +namespace mathematical_optimization { + +namespace { + +template +void copy_repeated_to_vector(const google::protobuf::RepeatedField& src, std::vector& dst) +{ + dst.assign(src.begin(), src.end()); +} + +template +void copy_vector_to_repeated(const std::vector& src, google::protobuf::RepeatedField* dst) +{ + dst->Clear(); + dst->Reserve(static_cast(src.size())); + for (auto v : src) { + dst->Add(v); + } +} + +void copy_u32_to_u8(const google::protobuf::RepeatedField& src, std::vector& dst) +{ + dst.clear(); + dst.reserve(static_cast(src.size())); + for (auto v : src) { + dst.push_back(static_cast(v)); + } +} + +void copy_bool_to_u8(const google::protobuf::RepeatedField& src, std::vector& dst) +{ + dst.clear(); + dst.reserve(static_cast(src.size())); + for (bool v : src) { + dst.push_back(v ? 1 : 0); + } +} + +cuopt::remote::RoutingSolutionStatus to_proto_status(cuopt::routing::solution_status_t s) +{ + using cuopt::routing::solution_status_t; + switch (s) { + case solution_status_t::SUCCESS: return cuopt::remote::ROUTING_SUCCESS; + case solution_status_t::INFEASIBLE: return cuopt::remote::ROUTING_INFEASIBLE; + case solution_status_t::TIMEOUT: return cuopt::remote::ROUTING_TIMEOUT; + case solution_status_t::EMPTY: return cuopt::remote::ROUTING_EMPTY; + case solution_status_t::ERROR: return cuopt::remote::ROUTING_ERROR; + } + return cuopt::remote::ROUTING_ERROR; +} + +} // namespace + +void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, + cuopt::routing::cpu_routing_problem_t& p) +{ + p = cuopt::routing::cpu_routing_problem_t{}; + p.num_locations = pb.num_locations(); + p.fleet_size = pb.fleet_size(); + p.num_orders = pb.num_orders(); + + for (auto const& cm : pb.cost_matrices()) { + cuopt::routing::cpu_cost_matrix_t out; + out.vehicle_type = static_cast(cm.vehicle_type()); + copy_repeated_to_vector(cm.values(), out.matrix); + p.cost_matrices.push_back(std::move(out)); + } + for (auto const& tm : pb.transit_time_matrices()) { + cuopt::routing::cpu_cost_matrix_t out; + out.vehicle_type = static_cast(tm.vehicle_type()); + copy_repeated_to_vector(tm.values(), out.matrix); + p.transit_time_matrices.push_back(std::move(out)); + } + + copy_repeated_to_vector(pb.vehicle_start_locations(), p.vehicle_start_locations); + copy_repeated_to_vector(pb.vehicle_return_locations(), p.vehicle_return_locations); + copy_repeated_to_vector(pb.vehicle_tw_earliest(), p.vehicle_tw_earliest); + copy_repeated_to_vector(pb.vehicle_tw_latest(), p.vehicle_tw_latest); + copy_u32_to_u8(pb.vehicle_types(), p.vehicle_types); + copy_bool_to_u8(pb.drop_return_trips(), p.drop_return_trips); + copy_bool_to_u8(pb.skip_first_trips(), p.skip_first_trips); + copy_repeated_to_vector(pb.vehicle_max_costs(), p.vehicle_max_costs); + copy_repeated_to_vector(pb.vehicle_max_times(), p.vehicle_max_times); + copy_repeated_to_vector(pb.vehicle_fixed_costs(), p.vehicle_fixed_costs); + + copy_repeated_to_vector(pb.order_locations(), p.order_locations); + copy_repeated_to_vector(pb.order_tw_earliest(), p.order_tw_earliest); + copy_repeated_to_vector(pb.order_tw_latest(), p.order_tw_latest); + copy_repeated_to_vector(pb.order_prizes(), p.order_prizes); + for (auto const& st : pb.order_service_times()) { + std::vector times; + copy_repeated_to_vector(st.service_times(), times); + p.order_service_times[st.vehicle_id()] = std::move(times); + } + + copy_repeated_to_vector(pb.pickup_indices(), p.pickup_indices); + copy_repeated_to_vector(pb.delivery_indices(), p.delivery_indices); + + for (auto const& dim : pb.capacity_dimensions()) { + cuopt::routing::cpu_capacity_dimension_t out; + out.name = dim.name(); + copy_repeated_to_vector(dim.demand(), out.demand); + copy_repeated_to_vector(dim.capacity(), out.capacity); + p.capacity_dimensions.push_back(std::move(out)); + } + + copy_repeated_to_vector(pb.break_locations(), p.break_locations); + for (auto const& ub : pb.uniform_breaks()) { + cuopt::routing::cpu_uniform_break_t out; + copy_repeated_to_vector(ub.earliest(), out.earliest); + copy_repeated_to_vector(ub.latest(), out.latest); + copy_repeated_to_vector(ub.duration(), out.duration); + p.uniform_breaks.push_back(std::move(out)); + } + for (auto const& pvb : pb.vehicle_breaks()) { + std::vector breaks; + for (auto const& b : pvb.breaks()) { + cuopt::routing::cpu_vehicle_break_t out; + out.earliest = b.earliest(); + out.latest = b.latest(); + out.duration = b.duration(); + copy_repeated_to_vector(b.locations(), out.locations); + breaks.push_back(std::move(out)); + } + p.vehicle_breaks[pvb.vehicle_id()] = std::move(breaks); + } + + for (auto const& m : pb.vehicle_order_match()) { + std::vector matches; + copy_repeated_to_vector(m.matches(), matches); + p.vehicle_order_match[m.id()] = std::move(matches); + } + for (auto const& m : pb.order_vehicle_match()) { + std::vector matches; + copy_repeated_to_vector(m.matches(), matches); + p.order_vehicle_match[m.id()] = std::move(matches); + } + for (auto const& prec : pb.order_precedence()) { + std::vector preceding; + copy_repeated_to_vector(prec.preceding_orders(), preceding); + p.order_precedence[prec.order_id()] = std::move(preceding); + } + + if (pb.has_objective()) { + copy_repeated_to_vector(pb.objective().objectives(), p.objectives); + copy_repeated_to_vector(pb.objective().weights(), p.objective_weights); + } + p.min_vehicles = pb.min_vehicles(); + + if (pb.has_initial_solutions()) { + auto const& init = pb.initial_solutions(); + copy_repeated_to_vector(init.vehicle_ids(), p.initial_solutions.vehicle_ids); + copy_repeated_to_vector(init.routes(), p.initial_solutions.routes); + copy_repeated_to_vector(init.types(), p.initial_solutions.types); + copy_repeated_to_vector(init.sol_offsets(), p.initial_solutions.sol_offsets); + } +} + +void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p, + cuopt::remote::RoutingProblem* pb) +{ + pb->Clear(); + pb->set_num_locations(p.num_locations); + pb->set_fleet_size(p.fleet_size); + pb->set_num_orders(p.num_orders); + + for (auto const& cm : p.cost_matrices) { + auto* out = pb->add_cost_matrices(); + out->set_vehicle_type(cm.vehicle_type); + copy_vector_to_repeated(cm.matrix, out->mutable_values()); + } + for (auto const& tm : p.transit_time_matrices) { + auto* out = pb->add_transit_time_matrices(); + out->set_vehicle_type(tm.vehicle_type); + copy_vector_to_repeated(tm.matrix, out->mutable_values()); + } + + copy_vector_to_repeated(p.vehicle_start_locations, pb->mutable_vehicle_start_locations()); + copy_vector_to_repeated(p.vehicle_return_locations, pb->mutable_vehicle_return_locations()); + copy_vector_to_repeated(p.vehicle_tw_earliest, pb->mutable_vehicle_tw_earliest()); + copy_vector_to_repeated(p.vehicle_tw_latest, pb->mutable_vehicle_tw_latest()); + for (auto v : p.vehicle_types) { + pb->add_vehicle_types(v); + } + for (auto v : p.drop_return_trips) { + pb->add_drop_return_trips(v != 0); + } + for (auto v : p.skip_first_trips) { + pb->add_skip_first_trips(v != 0); + } + copy_vector_to_repeated(p.vehicle_max_costs, pb->mutable_vehicle_max_costs()); + copy_vector_to_repeated(p.vehicle_max_times, pb->mutable_vehicle_max_times()); + copy_vector_to_repeated(p.vehicle_fixed_costs, pb->mutable_vehicle_fixed_costs()); + + copy_vector_to_repeated(p.order_locations, pb->mutable_order_locations()); + copy_vector_to_repeated(p.order_tw_earliest, pb->mutable_order_tw_earliest()); + copy_vector_to_repeated(p.order_tw_latest, pb->mutable_order_tw_latest()); + copy_vector_to_repeated(p.order_prizes, pb->mutable_order_prizes()); + for (auto const& [vehicle_id, times] : p.order_service_times) { + auto* out = pb->add_order_service_times(); + out->set_vehicle_id(vehicle_id); + copy_vector_to_repeated(times, out->mutable_service_times()); + } + + copy_vector_to_repeated(p.pickup_indices, pb->mutable_pickup_indices()); + copy_vector_to_repeated(p.delivery_indices, pb->mutable_delivery_indices()); + + for (auto const& dim : p.capacity_dimensions) { + auto* out = pb->add_capacity_dimensions(); + out->set_name(dim.name); + copy_vector_to_repeated(dim.demand, out->mutable_demand()); + copy_vector_to_repeated(dim.capacity, out->mutable_capacity()); + } + + copy_vector_to_repeated(p.break_locations, pb->mutable_break_locations()); + for (auto const& ub : p.uniform_breaks) { + auto* out = pb->add_uniform_breaks(); + copy_vector_to_repeated(ub.earliest, out->mutable_earliest()); + copy_vector_to_repeated(ub.latest, out->mutable_latest()); + copy_vector_to_repeated(ub.duration, out->mutable_duration()); + } + for (auto const& [vehicle_id, breaks] : p.vehicle_breaks) { + auto* out = pb->add_vehicle_breaks(); + out->set_vehicle_id(vehicle_id); + for (auto const& b : breaks) { + auto* brk = out->add_breaks(); + brk->set_earliest(b.earliest); + brk->set_latest(b.latest); + brk->set_duration(b.duration); + copy_vector_to_repeated(b.locations, brk->mutable_locations()); + } + } + + for (auto const& [id, matches] : p.vehicle_order_match) { + auto* out = pb->add_vehicle_order_match(); + out->set_id(id); + copy_vector_to_repeated(matches, out->mutable_matches()); + } + for (auto const& [id, matches] : p.order_vehicle_match) { + auto* out = pb->add_order_vehicle_match(); + out->set_id(id); + copy_vector_to_repeated(matches, out->mutable_matches()); + } + for (auto const& [order_id, preceding] : p.order_precedence) { + auto* out = pb->add_order_precedence(); + out->set_order_id(order_id); + copy_vector_to_repeated(preceding, out->mutable_preceding_orders()); + } + + if (!p.objectives.empty()) { + auto* obj = pb->mutable_objective(); + copy_vector_to_repeated(p.objectives, obj->mutable_objectives()); + copy_vector_to_repeated(p.objective_weights, obj->mutable_weights()); + } + pb->set_min_vehicles(p.min_vehicles); + + if (!p.initial_solutions.routes.empty()) { + auto* init = pb->mutable_initial_solutions(); + copy_vector_to_repeated(p.initial_solutions.vehicle_ids, init->mutable_vehicle_ids()); + copy_vector_to_repeated(p.initial_solutions.routes, init->mutable_routes()); + copy_vector_to_repeated(p.initial_solutions.types, init->mutable_types()); + copy_vector_to_repeated(p.initial_solutions.sol_offsets, init->mutable_sol_offsets()); + } +} + +void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, + cuopt::routing::solver_settings_t& settings) +{ + if (pb.time_limit() > 0) { settings.set_time_limit(pb.time_limit()); } + settings.set_verbose_mode(pb.verbose()); + settings.set_error_logging_mode(pb.error_logging()); + if (!pb.dump_best_results_path().empty()) { + settings.dump_best_results(pb.dump_best_results_path(), pb.dump_best_results_interval()); + } +} + +void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, + cuopt::remote::RoutingSolverSettings* pb) +{ + pb->Clear(); + pb->set_time_limit(settings.get_time_limit()); + pb->set_verbose(settings.get_verbose_mode()); + pb->set_error_logging(settings.get_error_logging_mode()); + auto [interval, dump, path] = settings.get_dump_best_results(); + if (dump) { + pb->set_dump_best_results_path(path); + pb->set_dump_best_results_interval(interval); + } +} + +void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, + const cuopt::routing::host_assignment_t& host, + cuopt::remote::RoutingSolution* pb) +{ + pb->Clear(); + copy_vector_to_repeated(host.route, pb->mutable_route()); + copy_vector_to_repeated(host.stamp, pb->mutable_arrival_stamp()); + copy_vector_to_repeated(host.truck_id, pb->mutable_truck_id()); + copy_vector_to_repeated(host.locations, pb->mutable_locations()); + copy_vector_to_repeated(host.node_types, pb->mutable_node_types()); + copy_vector_to_repeated(host.unserviced_nodes, pb->mutable_unserviced_nodes()); + copy_vector_to_repeated(host.accepted, pb->mutable_accepted()); + + pb->set_vehicle_count(assignment.get_vehicle_count()); + pb->set_total_objective_value(assignment.get_total_objective()); + for (auto const& [obj, value] : assignment.get_objectives()) { + (*pb->mutable_objective_values())[static_cast(obj)] = value; + } + + pb->set_status(to_proto_status(assignment.get_status())); + pb->set_status_message(assignment.get_status_string()); + if (assignment.get_status() == cuopt::routing::solution_status_t::ERROR) { + try { + pb->set_error_message(assignment.get_error_status().what()); + } catch (...) { + pb->set_error_message("routing solve error"); + } + } +} + +} // namespace mathematical_optimization +} // namespace cuopt diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/grpc_routing_problem_mapper.hpp new file mode 100644 index 0000000000..f061f37528 --- /dev/null +++ b/cpp/src/grpc/grpc_routing_problem_mapper.hpp @@ -0,0 +1,36 @@ +/* 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 { +namespace mathematical_optimization { + +void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, + cuopt::routing::cpu_routing_problem_t& problem); + +void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& problem, + cuopt::remote::RoutingProblem* pb); + +void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, + cuopt::routing::solver_settings_t& settings); + +void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, + cuopt::remote::RoutingSolverSettings* pb); + +void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, + const cuopt::routing::host_assignment_t& host, + cuopt::remote::RoutingSolution* pb); + +} // namespace mathematical_optimization +} // namespace cuopt diff --git a/cpp/src/grpc/server/grpc_service_impl.cpp b/cpp/src/grpc/server/grpc_service_impl.cpp index 5a3772be13..e6faa7cb93 100644 --- a/cpp/src/grpc/server/grpc_service_impl.cpp +++ b/cpp/src/grpc/server/grpc_service_impl.cpp @@ -23,6 +23,8 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S problem_category = cuopt::remote::LP; } else if (request->has_mip_request()) { problem_category = cuopt::remote::MIP; + } else if (request->has_vrp_request()) { + problem_category = cuopt::remote::VRP; } else { return Status(StatusCode::INVALID_ARGUMENT, "No problem data provided"); } @@ -41,9 +43,11 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S auto job_data = serialize_submit_request_to_pipe(*request); if (config.verbose) { - SERVER_LOG_DEBUG("[gRPC] SubmitJob: UNARY %s, pipe payload=%zu bytes", - problem_category == cuopt::remote::LP ? "LP" : "MIP", - job_data.size()); + const char* type_str = problem_category == cuopt::remote::LP + ? "LP" + : (problem_category == cuopt::remote::MIP ? "MIP" : "VRP"); + SERVER_LOG_DEBUG( + "[gRPC] SubmitJob: UNARY %s, pipe payload=%zu bytes", type_str, job_data.size()); } auto [ok, job_id] = submit_job_async(std::move(job_data), problem_category); @@ -53,9 +57,10 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S response->set_message("Job submitted successfully"); if (config.verbose) { - SERVER_LOG_DEBUG("[gRPC] Job submitted: %s (type=%s)", - job_id.c_str(), - problem_category == cuopt::remote::LP ? "LP" : "MIP"); + const char* type_str = problem_category == cuopt::remote::LP + ? "LP" + : (problem_category == cuopt::remote::MIP ? "MIP" : "VRP"); + SERVER_LOG_DEBUG("[gRPC] Job submitted: %s (type=%s)", job_id.c_str(), type_str); } return Status::OK; @@ -357,7 +362,9 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S // Build the full protobuf solution from the raw arrays that were read // back from the worker pipe by the result retrieval thread. - if (it->second.problem_category == cuopt::remote::MIP) { + if (it->second.problem_category == cuopt::remote::VRP || it->second.result_header.is_vrp()) { + response->set_routing_solution(it->second.result_header.routing_solution()); + } else if (it->second.problem_category == cuopt::remote::MIP) { cuopt::remote::MIPSolution mip_solution; cuopt::mathematical_optimization::build_mip_solution_proto( it->second.result_header, it->second.result_arrays, &mip_solution); diff --git a/cpp/src/grpc/server/grpc_worker.cpp b/cpp/src/grpc/server/grpc_worker.cpp index 373652f669..82e1a3f699 100644 --- a/cpp/src/grpc/server/grpc_worker.cpp +++ b/cpp/src/grpc/server/grpc_worker.cpp @@ -7,8 +7,13 @@ #include "grpc_incumbent_proto.hpp" #include "grpc_pipe_serialization.hpp" +#include "grpc_routing_problem_mapper.hpp" #include "grpc_server_types.hpp" +#include +#include +#include + #include #include @@ -20,6 +25,9 @@ using cuopt::mathematical_optimization::map_proto_to_mip_settings; using cuopt::mathematical_optimization::map_proto_to_pdlp_settings; using cuopt::mathematical_optimization::map_proto_to_problem; +using cuopt::mathematical_optimization::map_proto_to_routing_problem; +using cuopt::mathematical_optimization::map_proto_to_routing_settings; +using cuopt::mathematical_optimization::map_routing_solution_to_proto; namespace { @@ -91,7 +99,10 @@ struct DeserializedJob { cuopt::mathematical_optimization::cpu_optimization_problem_t problem; cuopt::mathematical_optimization::pdlp_solver_settings_t lp_settings; cuopt::mathematical_optimization::mip_solver_settings_t mip_settings; + cuopt::routing::cpu_routing_problem_t routing_problem; + cuopt::routing::solver_settings_t routing_settings; bool enable_incumbents = true; + bool is_vrp = false; bool success = false; }; @@ -287,6 +298,11 @@ static DeserializedJob read_problem_from_pipe(int worker_id, const JobQueueEntry auto pipe_recv_t0 = std::chrono::steady_clock::now(); if (is_chunked_job) { + // Chunked path: LP/MIP only for now (VRP is unary-only in this POC). + if (job.problem_category == cuopt::remote::VRP) { + SERVER_LOG_ERROR("[Worker] Chunked VRP upload is not supported"); + return dj; + } // Chunked path: the server wrote a ChunkedProblemHeader followed by // a set of raw typed arrays (constraint matrix, bounds, etc.). // This avoids a single giant protobuf allocation for large problems. @@ -337,7 +353,8 @@ static DeserializedJob read_problem_from_pipe(int worker_id, const JobQueueEntry cuopt::remote::SubmitJobRequest submit_request; if (!submit_request.ParseFromArray(request_data.data(), static_cast(request_data.size())) || - (!submit_request.has_lp_request() && !submit_request.has_mip_request())) { + (!submit_request.has_lp_request() && !submit_request.has_mip_request() && + !submit_request.has_vrp_request())) { return dj; } if (submit_request.has_lp_request()) { @@ -345,12 +362,18 @@ static DeserializedJob read_problem_from_pipe(int worker_id, const JobQueueEntry SERVER_LOG_INFO("[Worker] IPC path: UNARY LP (%zu bytes)", request_data.size()); map_proto_to_problem(req.problem(), dj.problem); map_proto_to_pdlp_settings(req.settings(), dj.lp_settings); - } else { + } else if (submit_request.has_mip_request()) { const auto& req = submit_request.mip_request(); SERVER_LOG_INFO("[Worker] IPC path: UNARY MIP (%zu bytes)", request_data.size()); map_proto_to_problem(req.problem(), dj.problem); map_proto_to_mip_settings(req.settings(), dj.mip_settings); dj.enable_incumbents = req.has_enable_incumbents() ? req.enable_incumbents() : true; + } else { + const auto& req = submit_request.vrp_request(); + SERVER_LOG_INFO("[Worker] IPC path: UNARY VRP (%zu bytes)", request_data.size()); + map_proto_to_routing_problem(req.problem(), dj.routing_problem); + map_proto_to_routing_settings(req.settings(), dj.routing_settings); + dj.is_vrp = true; } } @@ -509,6 +532,38 @@ static SolveResult run_lp_solve(DeserializedJob& dj, return sr; } +// Run the routing solver on the GPU and embed the RoutingSolution proto in +// ChunkedResultHeader (VRP results are typically small; no array chunking). +static SolveResult run_vrp_solve(DeserializedJob& dj, raft::handle_t& handle) +{ + SolveResult sr; + try { + auto [view, device_data] = dj.routing_problem.to_device(&handle); + auto assignment = cuopt::routing::solve(view, dj.routing_settings); + cuopt::routing::host_assignment_t host(assignment); + + cuopt::remote::RoutingSolution routing_solution; + map_routing_solution_to_proto(assignment, host, &routing_solution); + + sr.header.set_problem_category(cuopt::remote::VRP); + sr.header.set_is_vrp(true); + std::string serialized; + if (!routing_solution.SerializeToString(&serialized)) { + sr.error_message = "Failed to serialize RoutingSolution"; + return sr; + } + sr.header.set_routing_solution(std::move(serialized)); + SERVER_LOG_INFO("[Worker] Result path: VRP solution -> embedded RoutingSolution (%zu bytes)", + sr.header.routing_solution().size()); + sr.success = true; + } catch (const cuopt::logic_error& e) { + sr.error_message = format_cuopt_error(e); + } catch (const std::exception& e) { + sr.error_message = std::string("RuntimeError: ") + e.what(); + } + return sr; +} + // Publish a solve result: claim a slot in the shared-memory result_queue // (metadata) and, for successful solves, stream the full solution payload // through the worker's result pipe for the server thread to read. @@ -630,7 +685,9 @@ void worker_process(int worker_id) SERVER_LOG_INFO("[Worker %d] Processing job: %s (type: %s)", worker_id, job_id.c_str(), - problem_category == cuopt::remote::MIP ? "MIP" : "LP"); + problem_category == cuopt::remote::MIP + ? "MIP" + : (problem_category == cuopt::remote::VRP ? "VRP" : "LP")); auto deserialized = read_problem_from_pipe(worker_id, job); if (!deserialized.success) { @@ -640,17 +697,31 @@ void worker_process(int worker_id) continue; } - SERVER_LOG_INFO("[Worker] Problem reconstructed: %d constraints, %d variables, %d nonzeros", - deserialized.problem.get_n_constraints(), - deserialized.problem.get_n_variables(), - deserialized.problem.get_nnz()); + if (deserialized.is_vrp || problem_category == cuopt::remote::VRP) { + SERVER_LOG_INFO("[Worker] VRP problem reconstructed: %d locations, %d vehicles, %d orders", + deserialized.routing_problem.num_locations, + deserialized.routing_problem.fleet_size, + deserialized.routing_problem.num_orders < 0 + ? deserialized.routing_problem.num_locations + : deserialized.routing_problem.num_orders); + } else { + SERVER_LOG_INFO("[Worker] Problem reconstructed: %d constraints, %d variables, %d nonzeros", + deserialized.problem.get_n_constraints(), + deserialized.problem.get_n_variables(), + deserialized.problem.get_nnz()); + } std::string log_file = get_log_file_path(job_id); raft::handle_t handle; - SolveResult result = (problem_category == cuopt::remote::MIP) - ? run_mip_solve(deserialized, handle, log_file, job_id, worker_id) - : run_lp_solve(deserialized, handle, log_file); + SolveResult result; + if (problem_category == cuopt::remote::VRP || deserialized.is_vrp) { + result = run_vrp_solve(deserialized, handle); + } else if (problem_category == cuopt::remote::MIP) { + result = run_mip_solve(deserialized, handle, log_file, job_id, worker_id); + } else { + result = run_lp_solve(deserialized, handle, log_file); + } publish_result(result, job_id, worker_id); reset_job_slot(job); diff --git a/cpp/src/routing/CMakeLists.txt b/cpp/src/routing/CMakeLists.txt index 452c4806da..844eff833c 100644 --- a/cpp/src/routing/CMakeLists.txt +++ b/cpp/src/routing/CMakeLists.txt @@ -1,5 +1,5 @@ # cmake-format: off -# SPDX-FileCopyrightText: Copyright (c) 2024-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # cmake-format: on @@ -47,6 +47,7 @@ set(ROUTING_SRC_FILES ${CMAKE_CURRENT_SOURCE_DIR}/solve.cu ${CMAKE_CURRENT_SOURCE_DIR}/solver.cu ${CMAKE_CURRENT_SOURCE_DIR}/solver_settings.cu + ${CMAKE_CURRENT_SOURCE_DIR}/cpu_routing_problem.cu ${CMAKE_CURRENT_SOURCE_DIR}/utilities/check_input.cu ${CMAKE_CURRENT_SOURCE_DIR}/utilities/cython.cu) diff --git a/cpp/src/routing/cpu_routing_problem.cu b/cpp/src/routing/cpu_routing_problem.cu new file mode 100644 index 0000000000..5ce2b41bb2 --- /dev/null +++ b/cpp/src/routing/cpu_routing_problem.cu @@ -0,0 +1,305 @@ +/* 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 + +namespace cuopt { +namespace routing { + +struct cpu_routing_problem_t::device_data_t { + std::vector>> cost_matrices; + std::vector>> transit_time_matrices; + + std::unique_ptr> vehicle_start_locations; + std::unique_ptr> vehicle_return_locations; + std::unique_ptr> vehicle_tw_earliest; + std::unique_ptr> vehicle_tw_latest; + std::unique_ptr> vehicle_types; + std::unique_ptr> drop_return_trips; + std::unique_ptr> skip_first_trips; + std::unique_ptr> vehicle_max_costs; + std::unique_ptr> vehicle_max_times; + std::unique_ptr> vehicle_fixed_costs; + + std::unique_ptr> order_locations; + std::unique_ptr> order_tw_earliest; + std::unique_ptr> order_tw_latest; + std::unique_ptr> order_prizes; + + std::vector>> service_times; + std::vector service_time_vehicle_ids; + + std::unique_ptr> pickup_indices; + std::unique_ptr> delivery_indices; + + std::vector>> capacity_demands; + std::vector>> capacity_capacities; + std::vector capacity_names; + + std::unique_ptr> break_locations; + std::vector>> uniform_break_earliest; + std::vector>> uniform_break_latest; + std::vector>> uniform_break_duration; + + std::vector>> vehicle_break_locations; + + std::vector>> match_buffers; + std::vector>> precedence_buffers; + + std::unique_ptr> objectives; + std::unique_ptr> objective_weights; + + std::unique_ptr> init_vehicle_ids; + std::unique_ptr> init_routes; + std::unique_ptr> init_types; + std::unique_ptr> init_sol_offsets; +}; + +void cpu_routing_problem_t::device_data_deleter::operator()(device_data_t* p) const { delete p; } + +namespace { + +template +std::unique_ptr> copy_vector(std::vector const& host, + rmm::cuda_stream_view stream) +{ + if (host.empty()) { return nullptr; } + return std::make_unique>(cuopt::device_copy(host, stream)); +} + +std::unique_ptr> copy_u8_as_bool(std::vector const& host, + rmm::cuda_stream_view stream) +{ + if (host.empty()) { return nullptr; } + std::vector as_bool(host.begin(), host.end()); + return std::make_unique>(cuopt::device_copy(as_bool, stream)); +} + +} // namespace + +std::pair, cpu_routing_problem_t::device_data_ptr> +cpu_routing_problem_t::to_device(raft::handle_t* handle) const +{ + if (handle == nullptr) { + throw std::invalid_argument("cpu_routing_problem_t::to_device: handle must not be null"); + } + if (num_locations <= 0 || fleet_size <= 0) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: num_locations and fleet_size must be positive"); + } + if (cost_matrices.empty()) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: at least one cost matrix required"); + } + + auto stream = handle->get_stream(); + device_data_ptr data(new device_data_t()); + + int32_t orders = (num_orders < 0) ? num_locations : num_orders; + data_model_view_t view(handle, num_locations, fleet_size, orders); + + for (auto const& cm : cost_matrices) { + auto d = copy_vector(cm.matrix, stream); + if (!d) { throw std::invalid_argument("cpu_routing_problem_t::to_device: empty cost matrix"); } + view.add_cost_matrix(d->data(), cm.vehicle_type); + data->cost_matrices.push_back(std::move(d)); + } + + for (auto const& tm : transit_time_matrices) { + auto d = copy_vector(tm.matrix, stream); + if (!d) { + throw std::invalid_argument("cpu_routing_problem_t::to_device: empty transit time matrix"); + } + view.add_transit_time_matrix(d->data(), tm.vehicle_type); + data->transit_time_matrices.push_back(std::move(d)); + } + + if (!vehicle_start_locations.empty() && !vehicle_return_locations.empty()) { + data->vehicle_start_locations = copy_vector(vehicle_start_locations, stream); + data->vehicle_return_locations = copy_vector(vehicle_return_locations, stream); + view.set_vehicle_locations( + data->vehicle_start_locations->data(), data->vehicle_return_locations->data(), false); + } + + if (!vehicle_tw_earliest.empty() && !vehicle_tw_latest.empty()) { + data->vehicle_tw_earliest = copy_vector(vehicle_tw_earliest, stream); + data->vehicle_tw_latest = copy_vector(vehicle_tw_latest, stream); + view.set_vehicle_time_windows( + data->vehicle_tw_earliest->data(), data->vehicle_tw_latest->data(), false); + } + + if (!vehicle_types.empty()) { + data->vehicle_types = copy_vector(vehicle_types, stream); + view.set_vehicle_types(data->vehicle_types->data(), false); + } + + if (!drop_return_trips.empty()) { + data->drop_return_trips = copy_u8_as_bool(drop_return_trips, stream); + view.set_drop_return_trips(data->drop_return_trips->data()); + } + + if (!skip_first_trips.empty()) { + data->skip_first_trips = copy_u8_as_bool(skip_first_trips, stream); + view.set_skip_first_trips(data->skip_first_trips->data()); + } + + if (!vehicle_max_costs.empty()) { + data->vehicle_max_costs = copy_vector(vehicle_max_costs, stream); + view.set_vehicle_max_costs(data->vehicle_max_costs->data()); + } + + if (!vehicle_max_times.empty()) { + data->vehicle_max_times = copy_vector(vehicle_max_times, stream); + view.set_vehicle_max_times(data->vehicle_max_times->data()); + } + + if (!vehicle_fixed_costs.empty()) { + data->vehicle_fixed_costs = copy_vector(vehicle_fixed_costs, stream); + view.set_vehicle_fixed_costs(data->vehicle_fixed_costs->data()); + } + + if (!order_locations.empty()) { + data->order_locations = copy_vector(order_locations, stream); + view.set_order_locations(data->order_locations->data()); + } + + if (!order_tw_earliest.empty() && !order_tw_latest.empty()) { + data->order_tw_earliest = copy_vector(order_tw_earliest, stream); + data->order_tw_latest = copy_vector(order_tw_latest, stream); + view.set_order_time_windows( + data->order_tw_earliest->data(), data->order_tw_latest->data(), false); + } + + if (!order_prizes.empty()) { + data->order_prizes = copy_vector(order_prizes, stream); + view.set_order_prizes(data->order_prizes->data(), false); + } + + for (auto const& [vehicle_id, times] : order_service_times) { + auto d = copy_vector(times, stream); + if (!d) { continue; } + view.set_order_service_times(d->data(), vehicle_id, false); + data->service_time_vehicle_ids.push_back(vehicle_id); + data->service_times.push_back(std::move(d)); + } + + if (!pickup_indices.empty() && !delivery_indices.empty()) { + data->pickup_indices = copy_vector(pickup_indices, stream); + data->delivery_indices = copy_vector(delivery_indices, stream); + view.set_pickup_delivery_pairs(data->pickup_indices->data(), data->delivery_indices->data()); + } + + for (auto const& dim : capacity_dimensions) { + auto d_demand = copy_vector(dim.demand, stream); + auto d_capacity = copy_vector(dim.capacity, stream); + if (!d_demand || !d_capacity) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: incomplete capacity dimension"); + } + view.add_capacity_dimension(dim.name, d_demand->data(), d_capacity->data(), false); + data->capacity_names.push_back(dim.name); + data->capacity_demands.push_back(std::move(d_demand)); + data->capacity_capacities.push_back(std::move(d_capacity)); + } + + if (!break_locations.empty()) { + data->break_locations = copy_vector(break_locations, stream); + view.set_break_locations( + data->break_locations->data(), static_cast(data->break_locations->size()), false); + } + + for (auto const& ub : uniform_breaks) { + auto d_e = copy_vector(ub.earliest, stream); + auto d_l = copy_vector(ub.latest, stream); + auto d_d = copy_vector(ub.duration, stream); + if (!d_e || !d_l || !d_d) { + throw std::invalid_argument("cpu_routing_problem_t::to_device: incomplete uniform break"); + } + view.add_break_dimension(d_e->data(), d_l->data(), d_d->data(), false); + data->uniform_break_earliest.push_back(std::move(d_e)); + data->uniform_break_latest.push_back(std::move(d_l)); + data->uniform_break_duration.push_back(std::move(d_d)); + } + + for (auto const& [vehicle_id, breaks] : vehicle_breaks) { + for (auto const& brk : breaks) { + auto d_locs = copy_vector(brk.locations, stream); + int32_t n_locs = d_locs ? static_cast(d_locs->size()) : 0; + int32_t const* loc_ptr = d_locs ? d_locs->data() : nullptr; + view.add_vehicle_break( + vehicle_id, brk.earliest, brk.latest, brk.duration, loc_ptr, n_locs, false); + if (d_locs) { data->vehicle_break_locations.push_back(std::move(d_locs)); } + } + } + + for (auto const& [vehicle_id, orders] : vehicle_order_match) { + auto d = copy_vector(orders, stream); + if (!d) { continue; } + view.add_vehicle_order_match(vehicle_id, d->data(), static_cast(d->size()), false); + data->match_buffers.push_back(std::move(d)); + } + + for (auto const& [order_id, vehicles] : order_vehicle_match) { + auto d = copy_vector(vehicles, stream); + if (!d) { continue; } + view.add_order_vehicle_match(order_id, d->data(), static_cast(d->size()), false); + data->match_buffers.push_back(std::move(d)); + } + + for (auto const& [order_id, preceding] : order_precedence) { + auto d = copy_vector(preceding, stream); + if (!d) { continue; } + view.add_order_precedence(order_id, d->data(), static_cast(d->size())); + data->precedence_buffers.push_back(std::move(d)); + } + + if (!objectives.empty() && !objective_weights.empty()) { + // objective_t is an enum class; copy as int32 then reinterpret + data->objectives = copy_vector(objectives, stream); + data->objective_weights = copy_vector(objective_weights, stream); + view.set_objective_function(reinterpret_cast(data->objectives->data()), + data->objective_weights->data(), + static_cast(data->objectives->size())); + } + + if (min_vehicles > 0) { view.set_min_vehicles(min_vehicles); } + + if (!initial_solutions.routes.empty()) { + data->init_vehicle_ids = copy_vector(initial_solutions.vehicle_ids, stream); + data->init_routes = copy_vector(initial_solutions.routes, stream); + data->init_sol_offsets = copy_vector(initial_solutions.sol_offsets, stream); + + std::vector types; + types.reserve(initial_solutions.types.size()); + for (auto t : initial_solutions.types) { + types.push_back(static_cast(t)); + } + data->init_types = copy_vector(types, stream); + + int32_t n_nodes = static_cast(initial_solutions.routes.size()); + int32_t n_sols = static_cast(initial_solutions.sol_offsets.size()); + view.add_initial_solutions(data->init_vehicle_ids->data(), + data->init_routes->data(), + data->init_types->data(), + data->init_sol_offsets->data(), + n_nodes, + n_sols); + } + + handle->sync_stream(); + return {std::move(view), std::move(data)}; +} + +} // namespace routing +} // namespace cuopt diff --git a/cpp/tests/routing/CMakeLists.txt b/cpp/tests/routing/CMakeLists.txt index 569f84beb7..873fff8074 100644 --- a/cpp/tests/routing/CMakeLists.txt +++ b/cpp/tests/routing/CMakeLists.txt @@ -45,3 +45,9 @@ ConfigureTest(ROUTING_UNIT_TEST ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/batch_tsp.cu ${CMAKE_CURRENT_SOURCE_DIR}/unit_tests/set_shmem_of_kernel.cu LABELS routing) + +# ################################################################################################## +# - gRPC VRP test driver --------------------------------------------------------------------------- +if(CUOPT_ENABLE_GRPC) + add_subdirectory(grpc) +endif() diff --git a/cpp/tests/routing/grpc/CMakeLists.txt b/cpp/tests/routing/grpc/CMakeLists.txt new file mode 100644 index 0000000000..84757ae28d --- /dev/null +++ b/cpp/tests/routing/grpc/CMakeLists.txt @@ -0,0 +1,56 @@ +# cmake-format: off +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# cmake-format: on + +# Standalone VRP gRPC test driver (not a gtest). Requires a running cuopt_grpc_server. + +find_package(nlohmann_json QUIET) +if(NOT nlohmann_json_FOUND) + # Fall back to system header if the CMake package is unavailable. + find_path(NLOHMANN_JSON_INCLUDE_DIR nlohmann/json.hpp) + if(NOT NLOHMANN_JSON_INCLUDE_DIR) + message(WARNING "nlohmann_json not found; skipping GRPC_VRP_TEST_DRIVER") + return() + endif() +endif() + +add_executable(GRPC_VRP_TEST_DRIVER + ${CMAKE_CURRENT_SOURCE_DIR}/grpc_vrp_test_driver.cpp +) + +target_include_directories(GRPC_VRP_TEST_DRIVER + PRIVATE + "${CUOPT_SOURCE_DIR}/include" + "${CUOPT_SOURCE_DIR}/src/grpc" + "${CUOPT_SOURCE_DIR}/src/grpc/codegen/generated" + "${CMAKE_BINARY_DIR}" +) + +if(NLOHMANN_JSON_INCLUDE_DIR) + target_include_directories(GRPC_VRP_TEST_DRIVER PRIVATE "${NLOHMANN_JSON_INCLUDE_DIR}") +endif() + +target_link_libraries(GRPC_VRP_TEST_DRIVER + PRIVATE + cuopt + gRPC::grpc++ + protobuf::libprotobuf +) + +if(TARGET nlohmann_json::nlohmann_json) + target_link_libraries(GRPC_VRP_TEST_DRIVER PRIVATE nlohmann_json::nlohmann_json) +endif() + +if(NOT DEFINED INSTALL_TARGET OR "${INSTALL_TARGET}" STREQUAL "") + target_link_options(GRPC_VRP_TEST_DRIVER PRIVATE -Wl,--enable-new-dtags) +endif() + +add_dependencies(GRPC_VRP_TEST_DRIVER cuopt_grpc_server) + +install( + TARGETS GRPC_VRP_TEST_DRIVER + COMPONENT testing + DESTINATION bin/gtests/libcuopt + EXCLUDE_FROM_ALL +) diff --git a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp new file mode 100644 index 0000000000..3c2a91b433 --- /dev/null +++ b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp @@ -0,0 +1,298 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +/** + * Standalone VRP gRPC test driver. + * + * Reads a cuOpt service JSON file, builds a cpu_routing_problem_t, submits a + * SolveVRPRequest to cuopt_grpc_server, and prints the RoutingSolution. + * + * Usage: + * GRPC_VRP_TEST_DRIVER --server localhost:5001 \ + * --json datasets/cuopt_service_data/cuopt_problem_data.json + */ + +#include "grpc_routing_problem_mapper.hpp" + +#include + +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +using json = nlohmann::json; + +namespace { + +void print_usage(char const* argv0) +{ + std::cerr << "Usage: " << argv0 + << " --server HOST:PORT --json PATH [--time-limit SEC] [--verbose]\n"; +} + +cuopt::routing::cpu_routing_problem_t load_cuopt_json(std::string const& path) +{ + std::ifstream in(path); + if (!in) { throw std::runtime_error("Failed to open JSON: " + path); } + json root; + in >> root; + + cuopt::routing::cpu_routing_problem_t p; + + // Cost matrix (vehicle type "0" by default) + auto const& cm_data = root.at("cost_matrix_data").at("data"); + for (auto it = cm_data.begin(); it != cm_data.end(); ++it) { + cuopt::routing::cpu_cost_matrix_t cm; + cm.vehicle_type = static_cast(std::stoul(it.key())); + auto const& mat = it.value(); + p.num_locations = static_cast(mat.size()); + cm.matrix.reserve(static_cast(p.num_locations) * p.num_locations); + for (auto const& row : mat) { + for (auto const& v : row) { + cm.matrix.push_back(v.get()); + } + } + p.cost_matrices.push_back(std::move(cm)); + } + + auto const& fleet = root.at("fleet_data"); + auto const& locs = fleet.at("vehicle_locations"); + p.fleet_size = static_cast(locs.size()); + for (auto const& pair : locs) { + p.vehicle_start_locations.push_back(pair.at(0).get()); + p.vehicle_return_locations.push_back(pair.at(1).get()); + } + + if (fleet.contains("vehicle_time_windows") && !fleet["vehicle_time_windows"].is_null()) { + for (auto const& tw : fleet.at("vehicle_time_windows")) { + p.vehicle_tw_earliest.push_back(tw.at(0).get()); + p.vehicle_tw_latest.push_back(tw.at(1).get()); + } + } + if (fleet.contains("skip_first_trips") && !fleet["skip_first_trips"].is_null()) { + for (auto const& v : fleet.at("skip_first_trips")) { + p.skip_first_trips.push_back(v.get() ? 1 : 0); + } + } + if (fleet.contains("drop_return_trips") && !fleet["drop_return_trips"].is_null()) { + for (auto const& v : fleet.at("drop_return_trips")) { + p.drop_return_trips.push_back(v.get() ? 1 : 0); + } + } + if (fleet.contains("vehicle_max_costs") && !fleet["vehicle_max_costs"].is_null()) { + for (auto const& v : fleet.at("vehicle_max_costs")) { + p.vehicle_max_costs.push_back(v.get()); + } + } + + auto const& tasks = root.at("task_data"); + if (tasks.contains("task_locations") && !tasks["task_locations"].is_null()) { + for (auto const& loc : tasks.at("task_locations")) { + p.order_locations.push_back(loc.get()); + } + p.num_orders = static_cast(p.order_locations.size()); + } else { + p.num_orders = p.num_locations; + } + + // Capacities: fleet_data.capacities is [dim][vehicle]; task_data.demand is [dim][order] + if (fleet.contains("capacities") && !fleet["capacities"].is_null() && tasks.contains("demand") && + !tasks["demand"].is_null()) { + auto const& caps = fleet.at("capacities"); + auto const& demand = tasks.at("demand"); + size_t n_dims = caps.size(); + for (size_t d = 0; d < n_dims; ++d) { + cuopt::routing::cpu_capacity_dimension_t dim; + dim.name = "dim_" + std::to_string(d); + for (auto const& c : caps.at(d)) { + dim.capacity.push_back(c.get()); + } + for (auto const& dem : demand.at(d)) { + dim.demand.push_back(dem.get()); + } + p.capacity_dimensions.push_back(std::move(dim)); + } + } + + if (tasks.contains("task_time_windows") && !tasks["task_time_windows"].is_null()) { + for (auto const& tw : tasks.at("task_time_windows")) { + p.order_tw_earliest.push_back(tw.at(0).get()); + p.order_tw_latest.push_back(tw.at(1).get()); + } + } + if (tasks.contains("service_times") && !tasks["service_times"].is_null()) { + std::vector times; + for (auto const& t : tasks.at("service_times")) { + times.push_back(t.get()); + } + p.order_service_times[-1] = std::move(times); + } + + return p; +} + +void print_solution(cuopt::remote::RoutingSolution const& sol) +{ + std::cout << "status=" << static_cast(sol.status()) << " (" << sol.status_message() << ")\n"; + if (!sol.error_message().empty()) { std::cout << "error: " << sol.error_message() << "\n"; } + std::cout << "vehicle_count=" << sol.vehicle_count() + << " total_objective=" << sol.total_objective_value() << "\n"; + std::cout << "route (" << sol.route_size() << "):"; + for (int i = 0; i < sol.route_size(); ++i) { + std::cout << " " << sol.route(i); + } + std::cout << "\ntruck_id:"; + for (int i = 0; i < sol.truck_id_size(); ++i) { + std::cout << " " << sol.truck_id(i); + } + std::cout << "\nlocations:"; + for (int i = 0; i < sol.locations_size(); ++i) { + std::cout << " " << sol.locations(i); + } + std::cout << "\nunserviced:"; + for (int i = 0; i < sol.unserviced_nodes_size(); ++i) { + std::cout << " " << sol.unserviced_nodes(i); + } + std::cout << "\n"; +} + +} // namespace + +int main(int argc, char** argv) +{ + std::string server; + std::string json_path; + float time_limit = 10.0f; + bool verbose = false; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--server" && i + 1 < argc) { + server = argv[++i]; + } else if (arg == "--json" && i + 1 < argc) { + json_path = argv[++i]; + } else if (arg == "--time-limit" && i + 1 < argc) { + time_limit = std::stof(argv[++i]); + } else if (arg == "--verbose") { + verbose = true; + } else if (arg == "--help" || arg == "-h") { + print_usage(argv[0]); + return 0; + } else { + std::cerr << "Unknown argument: " << arg << "\n"; + print_usage(argv[0]); + return 1; + } + } + + if (server.empty() || json_path.empty()) { + print_usage(argv[0]); + return 1; + } + + try { + std::cout << "Loading cuOpt JSON: " << json_path << "\n"; + auto problem = load_cuopt_json(json_path); + std::cout << "Problem: " << problem.num_locations << " locations, " << problem.fleet_size + << " vehicles, " + << (problem.num_orders < 0 ? problem.num_locations : problem.num_orders) + << " orders, " << problem.capacity_dimensions.size() << " capacity dims, " + << problem.cost_matrices.size() << " cost matrices\n"; + + cuopt::remote::SubmitJobRequest submit_req; + auto* vrp = submit_req.mutable_vrp_request(); + vrp->mutable_header()->set_version(1); + vrp->mutable_header()->set_problem_category(cuopt::remote::VRP); + cuopt::mathematical_optimization::map_routing_problem_to_proto(problem, vrp->mutable_problem()); + vrp->mutable_settings()->set_time_limit(time_limit); + vrp->mutable_settings()->set_verbose(verbose); + + std::cout << "Request proto size: " << submit_req.ByteSizeLong() << " bytes\n"; + std::cout << "Connecting to " << server << "...\n"; + + auto channel = grpc::CreateChannel(server, grpc::InsecureChannelCredentials()); + auto stub = cuopt::remote::CuOptRemoteService::NewStub(channel); + + cuopt::remote::SubmitJobResponse submit_resp; + { + grpc::ClientContext ctx; + auto status = stub->SubmitJob(&ctx, submit_req, &submit_resp); + if (!status.ok()) { + std::cerr << "SubmitJob failed: " << status.error_message() << "\n"; + return 1; + } + } + std::string job_id = submit_resp.job_id(); + std::cout << "Submitted job_id=" << job_id << "\n"; + + // Poll until complete + for (;;) { + cuopt::remote::StatusRequest status_req; + status_req.set_job_id(job_id); + cuopt::remote::StatusResponse status_resp; + grpc::ClientContext ctx; + auto status = stub->CheckStatus(&ctx, status_req, &status_resp); + if (!status.ok()) { + std::cerr << "CheckStatus failed: " << status.error_message() << "\n"; + return 1; + } + auto js = status_resp.job_status(); + if (js == cuopt::remote::COMPLETED || js == cuopt::remote::FAILED || + js == cuopt::remote::CANCELLED) { + std::cout << "Job finished with status=" << static_cast(js) << "\n"; + if (js != cuopt::remote::COMPLETED) { + std::cerr << "message: " << status_resp.message() << "\n"; + return 1; + } + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + cuopt::remote::GetResultRequest result_req; + result_req.set_job_id(job_id); + cuopt::remote::ResultResponse result_resp; + { + grpc::ClientContext ctx; + auto status = stub->GetResult(&ctx, result_req, &result_resp); + if (!status.ok()) { + std::cerr << "GetResult failed: " << status.error_message() << "\n"; + return 1; + } + } + + if (result_resp.status() != cuopt::remote::SUCCESS) { + std::cerr << "Result error: " << result_resp.error_message() << "\n"; + return 1; + } + if (!result_resp.has_routing_solution()) { + std::cerr << "Result missing routing_solution blob\n"; + return 1; + } + + cuopt::remote::RoutingSolution sol; + if (!sol.ParseFromString(result_resp.routing_solution())) { + std::cerr << "Failed to parse RoutingSolution\n"; + return 1; + } + print_solution(sol); + return 0; + } catch (std::exception const& e) { + std::cerr << "Error: " << e.what() << "\n"; + return 1; + } +} From 818bf71436f0633d3283b78f403839ed2fec7c58 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 09:29:20 -0500 Subject: [PATCH 02/17] Remove to_host_problem; pivot VRP gRPC to a single C++/Cython client The routing gRPC path will use one architecture, consistent with LP/MILP: a compiled C++/Cython client that serializes a host cpu_routing_problem_t in C++ (map_routing_problem_to_proto) rather than a parallel pure-Python protobuf path. Remove the Phase 3 Python export layer (to_host_problem / _serialize.py and its test): the store-then-build IR is instead walked in the Cython client to fill cpu_routing_problem_t, so the mapping lives in one place and there is no second serializer to maintain. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- python/cuopt/cuopt/routing/_serialize.py | 240 ------------------ .../cuopt/tests/routing/test_serialize.py | 137 ---------- 2 files changed, 377 deletions(-) delete mode 100644 python/cuopt/cuopt/routing/_serialize.py delete mode 100644 python/cuopt/cuopt/tests/routing/test_serialize.py diff --git a/python/cuopt/cuopt/routing/_serialize.py b/python/cuopt/cuopt/routing/_serialize.py deleted file mode 100644 index 47298f54e5..0000000000 --- a/python/cuopt/cuopt/routing/_serialize.py +++ /dev/null @@ -1,240 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Export a recorded routing problem (the store-then-build IR) to host arrays. - -Walks ``DataModel._calls`` and produces plain host (numpy) arrays keyed by the -gRPC ``RoutingProblem`` field names, exporting any device (cuDF/cupy) inputs to -host at this point -- the "export on serialize" step of the mixed IR. The result -is proto-agnostic (a dict of host arrays) so a protobuf/gRPC layer can map it -onto the wire without this module depending on generated stubs. - -Most setters map 1:1 -- ``set_(array)`` writes host field ```` -- -and are handled automatically, so they need no entry here. Only setters that -rename fields, take several arguments, or build nested/keyed structures get an -explicit handler in ``_HANDLERS``. A recorded ``add_*`` setter with no handler -raises, and ``test_serialize`` checks every recorded setter is exportable, so a -new unmapped setter fails loudly instead of being silently dropped. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import numpy as np - -if TYPE_CHECKING: - from cuopt.routing.vehicle_routing import DataModel - - -def _to_host(x): - """Return a host numpy view/copy of ``x`` (numpy/pandas/cuDF/cupy/list).""" - if isinstance(x, np.ndarray): - return x - root = type(x).__module__.split(".", 1)[0] - if root in ("pandas", "cudf"): - return x.to_numpy() - if root == "cupy": - return x.get() - return np.asarray(x) - - -# --- handlers for setters that do NOT map set_(array) -> field --- - - -def _matrix(key): - def handle(p, args): - mat = _to_host(args[0]).astype(np.float32, copy=False) - vehicle_type = int(args[1]) if len(args) > 1 else 0 - p[key].append( - {"vehicle_type": vehicle_type, "values": mat.ravel(order="C")} - ) - - return handle - - -def _pair(key0, key1): - def handle(p, args): - p[key0] = _to_host(args[0]) - p[key1] = _to_host(args[1]) - - return handle - - -def _match(key): - def handle(p, args): - p[key].append({"id": int(args[0]), "matches": _to_host(args[1])}) - - return handle - - -def _scalar(key): - def handle(p, args): - p[key] = int(args[0]) - - return handle - - -def _capacity(p, args): - p["capacity_dimensions"].append( - { - "name": args[0], - "demand": _to_host(args[1]), - "capacity": _to_host(args[2]), - } - ) - - -def _service_times(p, args): - vehicle_id = int(args[1]) if len(args) > 1 else -1 - p["order_service_times"].append( - {"vehicle_id": vehicle_id, "service_times": _to_host(args[0])} - ) - - -def _precedence(p, args): - p["order_precedence"].append( - {"order_id": int(args[0]), "preceding_orders": _to_host(args[1])} - ) - - -def _objective(p, args): - p["objective"] = { - "objectives": _to_host(args[0]), - "weights": _to_host(args[1]), - } - - -def _initial_solutions(p, args): - p["initial_solutions"] = { - "vehicle_ids": _to_host(args[0]), - "routes": _to_host(args[1]), - "types": _to_host(args[2]), - "sol_offsets": _to_host(args[3]), - } - - -def _uniform_break(p, args): - p["uniform_breaks"].append( - { - "earliest": _to_host(args[0]), - "latest": _to_host(args[1]), - "duration": _to_host(args[2]), - } - ) - - -def _vehicle_break(p, args): - vehicle_id = int(args[0]) - locations = args[4] if len(args) > 4 else None - brk = { - "earliest": int(args[1]), - "latest": int(args[2]), - "duration": int(args[3]), - "locations": ( - _to_host(locations) - if locations is not None - else np.empty(0, np.int32) - ), - } - entry = next( - (e for e in p["vehicle_breaks"] if e["vehicle_id"] == vehicle_id), None - ) - if entry is None: - entry = {"vehicle_id": vehicle_id, "breaks": []} - p["vehicle_breaks"].append(entry) - entry["breaks"].append(brk) - - -_HANDLERS = { - "add_cost_matrix": _matrix("cost_matrices"), - "add_transit_time_matrix": _matrix("transit_time_matrices"), - "set_order_time_windows": _pair("order_tw_earliest", "order_tw_latest"), - "set_vehicle_time_windows": _pair( - "vehicle_tw_earliest", "vehicle_tw_latest" - ), - "set_vehicle_locations": _pair( - "vehicle_start_locations", "vehicle_return_locations" - ), - "set_pickup_delivery_pairs": _pair("pickup_indices", "delivery_indices"), - "add_capacity_dimension": _capacity, - "set_order_service_times": _service_times, - "add_vehicle_order_match": _match("vehicle_order_match"), - "add_order_vehicle_match": _match("order_vehicle_match"), - "add_order_precedence": _precedence, - "add_break_dimension": _uniform_break, - "add_vehicle_break": _vehicle_break, - "set_objective_function": _objective, - "add_initial_solutions": _initial_solutions, - "set_min_vehicles": _scalar("min_vehicles"), -} - -# list-valued fields the handlers append to (pre-initialized so order of calls -# does not matter). -_LIST_FIELDS = ( - "cost_matrices", - "transit_time_matrices", - "capacity_dimensions", - "order_service_times", - "vehicle_order_match", - "order_vehicle_match", - "order_precedence", - "uniform_breaks", - "vehicle_breaks", -) - - -def to_host_problem(dm: DataModel) -> dict[str, Any]: - """Export a recorded routing problem to host arrays keyed by proto fields. - - Parameters - ---------- - dm : cuopt.routing.DataModel - A routing data model whose setter calls have been recorded (the - store-then-build IR). Device (cuDF/cupy) inputs are copied to host - here; host inputs are already numpy in the IR. - - Returns - ------- - dict - Host (numpy) arrays and scalars keyed by the gRPC ``RoutingProblem`` - field names (e.g. ``cost_matrices``, ``order_locations``, - ``capacity_dimensions``). List-valued fields are always present - (possibly empty); other fields appear only when the corresponding - setter was called. - - Raises - ------ - KeyError - If a recorded ``add_*`` setter, or a multi-argument ``set_*`` setter, - has no export mapping. Add an explicit handler to ``_HANDLERS``. - """ - n_loc, fleet, n_ord = dm._init_args - p = { - "num_locations": int(n_loc), - "fleet_size": int(fleet), - "num_orders": int(n_loc if n_ord == -1 else n_ord), - } - for key in _LIST_FIELDS: - p[key] = [] - - for name, args, _ in dm._calls: - handler = _HANDLERS.get(name) - if handler is not None: - handler(p, args) - elif name.startswith("set_"): - # 1:1 single-array setter: set_(array) -> field . - # A multi-argument set_* needs an explicit handler; refuse to - # export it here rather than silently drop the extra arguments. - if len(args) != 1: - raise KeyError( - f"no 1:1 export mapping for recorded setter {name!r}; add" - " a handler to _serialize._HANDLERS" - ) - p[name[len("set_") :]] = _to_host(args[0]) - else: - raise KeyError( - f"no export mapping for recorded setter {name!r}; add a handler" - " to _serialize._HANDLERS" - ) - return p diff --git a/python/cuopt/cuopt/tests/routing/test_serialize.py b/python/cuopt/cuopt/tests/routing/test_serialize.py deleted file mode 100644 index d99f0836f8..0000000000 --- a/python/cuopt/cuopt/tests/routing/test_serialize.py +++ /dev/null @@ -1,137 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import numpy as np -import pytest - -import cudf - -from cuopt import routing -from cuopt.routing._deferred import _SETTERS -from cuopt.routing._serialize import _HANDLERS, to_host_problem - - -def test_every_setter_is_exportable(): - """Every recorded setter must be exportable -- either it has an explicit - handler or it maps 1:1 (``set_``). Fails loudly if a new setter is - added without an export mapping, instead of silently dropping its data. - """ - unmapped = [ - n for n in _SETTERS if n not in _HANDLERS and not n.startswith("set_") - ] - assert not unmapped, f"setters with no export mapping: {unmapped}" - - -COST = np.array( - [ - [0, 4, 5, 2, 7], - [3, 0, 6, 8, 1], - [5, 2, 0, 4, 9], - [6, 3, 7, 0, 2], - [1, 8, 4, 5, 0], - ], - dtype=np.float32, -) - - -def _has_device_ref(o): - if isinstance(o, dict): - return any(_has_device_ref(v) for v in o.values()) - if isinstance(o, list): - return any(_has_device_ref(v) for v in o) - return hasattr(o, "__cuda_array_interface__") - - -def test_export_exports_host_and_device_to_host(): - d = routing.DataModel(5, 2) - d.add_cost_matrix(COST) # host (numpy) - d.add_cost_matrix(cudf.DataFrame(COST + 1), 1) # device (cuDF) - d.set_order_time_windows( - np.array([0, 0, 0, 0, 0], np.int32), np.array([9] * 5, np.int32) - ) - d.set_order_prizes(cudf.Series([0, 2, 2, 2, 2]).astype("float32")) - d.add_capacity_dimension( - "demand", - np.array([0, 1, 1, 1, 1], np.int32), - np.array([10, 10], np.int32), - ) - d.set_min_vehicles(1) - - p = to_host_problem(d) - - assert (p["num_locations"], p["fleet_size"], p["num_orders"]) == (5, 2, 5) - # matrices export row-major float32 with the right vehicle_type; host and - # device inputs both land on host. - m0, m1 = p["cost_matrices"] - assert m0["vehicle_type"] == 0 and m0["values"].dtype == np.float32 - np.testing.assert_array_equal(m0["values"], COST.ravel(order="C")) - assert m1["vehicle_type"] == 1 and m1["values"].dtype == np.float32 - np.testing.assert_array_equal(m1["values"], (COST + 1).ravel(order="C")) - # order time windows (both arrays) and prizes (device -> host) - np.testing.assert_array_equal( - p["order_tw_earliest"], np.zeros(5, np.int32) - ) - np.testing.assert_array_equal( - p["order_tw_latest"], np.full(5, 9, np.int32) - ) - np.testing.assert_array_equal( - p["order_prizes"], np.array([0, 2, 2, 2, 2], np.float32) - ) - # capacity dimension: name and both arrays - cap = p["capacity_dimensions"][0] - assert cap["name"] == "demand" - np.testing.assert_array_equal( - cap["demand"], np.array([0, 1, 1, 1, 1], np.int32) - ) - np.testing.assert_array_equal( - cap["capacity"], np.array([10, 10], np.int32) - ) - assert p["min_vehicles"] == 1 - # the exported problem holds no device references - assert not _has_device_ref(p) - - -def test_export_breaks(): - d = routing.DataModel(4, 2) - d.add_cost_matrix(np.zeros((4, 4), np.float32)) - d.add_break_dimension( - np.array([10, 10], np.int32), - np.array([20, 20], np.int32), - np.array([5, 5], np.int32), - ) - # two breaks for the same vehicle; second has device locations - d.add_vehicle_break(0, 10, 20, 5, np.array([1, 2], np.int32)) - d.add_vehicle_break(0, 30, 40, 5, cudf.Series([3]).astype("int32")) - - p = to_host_problem(d) - - ub = p["uniform_breaks"][0] - np.testing.assert_array_equal(ub["earliest"], np.array([10, 10], np.int32)) - np.testing.assert_array_equal(ub["latest"], np.array([20, 20], np.int32)) - np.testing.assert_array_equal(ub["duration"], np.array([5, 5], np.int32)) - - veh0 = p["vehicle_breaks"][0] - assert veh0["vehicle_id"] == 0 and len(veh0["breaks"]) == 2 - b0, b1 = veh0["breaks"] - assert (b0["earliest"], b0["latest"], b0["duration"]) == (10, 20, 5) - np.testing.assert_array_equal(b0["locations"], np.array([1, 2], np.int32)) - # second break's locations came in as cuDF -> exported to host - assert (b1["earliest"], b1["latest"], b1["duration"]) == (30, 40, 5) - np.testing.assert_array_equal(b1["locations"], np.array([3], np.int32)) - assert not _has_device_ref(p) - - -def test_multi_arg_set_without_handler_raises(): - """A set_* call that is not a single-array setter (and has no handler) must - raise rather than silently drop its extra arguments. - """ - fake = type( - "FakeDM", - (), - { - "_init_args": (1, 1, -1), - "_calls": [("set_two_args", (np.zeros(1), np.zeros(1)), {})], - }, - )() - with pytest.raises(KeyError): - to_host_problem(fake) From ce7594299b2096d96f2309803f93915ea61b2893 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 10:39:33 -0500 Subject: [PATCH 03/17] Add compiled C++/Cython VRP gRPC client (single architecture) A pure-Python client would need a second serialization path; instead follow the LP/MILP pattern so routing has one architecture: - C++: cpu_routing_solution_t + map_proto_to_routing_solution (client-side parse of the RoutingSolution blob); submit_vrp/get_vrp_result/solve_vrp on grpc_client_t (unary-only, mirroring submit_lp); accept routing_solution in the unary GetResult path. - cython shim (grpc_python_client_t): submit_vrp/result_vrp exposing the plain host structs to Cython (no grpc/protobuf in the extension build). - Cython client (cuopt.grpc.routing.RoutingClient): walks the DataModel store-then-build IR to fill cpu_routing_problem_t -- the single mapping site that replaces to_host_problem -- then drives submit/wait/result and returns the parsed solution. Validated end-to-end against cuopt_grpc_server: remote solve matches local (objective, route, vehicle count) for cost-matrix, capacity, and time-window problems. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/include/cuopt/grpc/cython_grpc_client.hpp | 24 ++ .../cuopt/routing/cpu_routing_problem.hpp | 22 ++ cpp/src/grpc/client/cython_grpc_client.cpp | 30 ++ cpp/src/grpc/client/grpc_client.cpp | 86 ++++- cpp/src/grpc/client/grpc_client.hpp | 30 ++ cpp/src/grpc/grpc_routing_problem_mapper.cpp | 22 ++ cpp/src/grpc/grpc_routing_problem_mapper.hpp | 4 + python/cuopt/cuopt/grpc/CMakeLists.txt | 1 + .../cuopt/cuopt/grpc/routing/CMakeLists.txt | 15 + python/cuopt/cuopt/grpc/routing/__init__.py | 21 ++ .../cuopt/cuopt/grpc/routing/grpc_client.pxd | 151 +++++++++ .../cuopt/cuopt/grpc/routing/grpc_client.pyx | 318 ++++++++++++++++++ .../cuopt/tests/routing/test_grpc_client.py | 66 ++++ 13 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 python/cuopt/cuopt/grpc/routing/CMakeLists.txt create mode 100644 python/cuopt/cuopt/grpc/routing/__init__.py create mode 100644 python/cuopt/cuopt/grpc/routing/grpc_client.pxd create mode 100644 python/cuopt/cuopt/grpc/routing/grpc_client.pyx create mode 100644 python/cuopt/cuopt/tests/routing/test_grpc_client.py diff --git a/cpp/include/cuopt/grpc/cython_grpc_client.hpp b/cpp/include/cuopt/grpc/cython_grpc_client.hpp index 593014cedc..99e36a455d 100644 --- a/cpp/include/cuopt/grpc/cython_grpc_client.hpp +++ b/cpp/include/cuopt/grpc/cython_grpc_client.hpp @@ -6,6 +6,7 @@ #pragma once #include +#include #include #include @@ -22,6 +23,11 @@ class data_model_view_t; } // namespace io } // namespace cuopt::mathematical_optimization +namespace cuopt::routing { +template +class solver_settings_t; +} // namespace cuopt::routing + namespace cuopt::cython { /** Mirrors cuopt::mathematical_optimization::job_status_t for the Python bindings. */ @@ -56,6 +62,13 @@ struct grpc_result_outcome_t { std::unique_ptr solution; }; +struct grpc_vrp_result_outcome_t { + bool not_ready = false; + bool success = false; + std::string error_message; + cuopt::routing::cpu_routing_solution_t solution; +}; + struct grpc_logs_result_t { bool success = false; std::string error_message; @@ -136,6 +149,17 @@ class grpc_python_client_t { */ grpc_result_outcome_t result(const std::string& job_id, bool is_mip); + /** + * @brief Submit a VRP problem (unary only). Reuse status/wait/delete/result_vrp. + */ + grpc_submit_result_t submit_vrp(cuopt::routing::cpu_routing_problem_t* problem, + cuopt::routing::solver_settings_t* settings); + + /** + * @brief Fetch and parse a completed VRP job's routing solution. + */ + grpc_vrp_result_outcome_t result_vrp(const std::string& job_id); + /** * @brief Block until the job completes, collecting all solver log lines. */ diff --git a/cpp/include/cuopt/routing/cpu_routing_problem.hpp b/cpp/include/cuopt/routing/cpu_routing_problem.hpp index 6e4aa0f4cd..ba58019e7c 100644 --- a/cpp/include/cuopt/routing/cpu_routing_problem.hpp +++ b/cpp/include/cuopt/routing/cpu_routing_problem.hpp @@ -123,5 +123,27 @@ class cpu_routing_problem_t { std::pair, device_data_ptr> to_device(raft::handle_t* handle) const; }; +/** + * @brief Host-memory routing solution (remote-execution analog of the parsed + * RoutingSolution proto). Populated on the client from the server's response. + */ +struct cpu_routing_solution_t { + std::vector route; + std::vector arrival_stamp; + std::vector truck_id; + std::vector locations; + std::vector node_types; + std::vector unserviced_nodes; + std::vector accepted; + + int32_t vehicle_count = 0; + double total_objective_value = 0.0; + std::map objective_values; + + int32_t status = 0; // cuopt.remote.RoutingSolutionStatus (0 == SUCCESS) + std::string status_message; + std::string error_message; +}; + } // namespace routing } // namespace cuopt diff --git a/cpp/src/grpc/client/cython_grpc_client.cpp b/cpp/src/grpc/client/cython_grpc_client.cpp index 9ba11459b5..f12d8af88b 100644 --- a/cpp/src/grpc/client/cython_grpc_client.cpp +++ b/cpp/src/grpc/client/cython_grpc_client.cpp @@ -151,6 +151,36 @@ grpc_submit_result_t grpc_python_client_t::submit( return out; } +grpc_submit_result_t grpc_python_client_t::submit_vrp( + cuopt::routing::cpu_routing_problem_t* problem, + cuopt::routing::solver_settings_t* settings) +{ + grpc_submit_result_t out; + if (problem == nullptr || settings == nullptr) { + out.error_message = "problem and settings must not be null"; + return out; + } + auto sub = impl_->client.submit_vrp(*problem, *settings); + out.success = sub.success; + out.error_message = sub.error_message; + out.job_id = sub.job_id; + out.is_mip = false; + return out; +} + +grpc_vrp_result_outcome_t grpc_python_client_t::result_vrp(const std::string& job_id) +{ + grpc_vrp_result_outcome_t out; + auto remote = impl_->client.get_vrp_result(job_id); + if (!remote.success) { + out.error_message = remote.error_message; + return out; + } + out.success = true; + out.solution = std::move(remote.solution); + return out; +} + grpc_status_result_t grpc_python_client_t::status(const std::string& job_id) { return map_status_result(impl_->client.check_status(job_id)); diff --git a/cpp/src/grpc/client/grpc_client.cpp b/cpp/src/grpc/client/grpc_client.cpp index ac28a2f767..d45b21da39 100644 --- a/cpp/src/grpc/client/grpc_client.cpp +++ b/cpp/src/grpc/client/grpc_client.cpp @@ -9,6 +9,7 @@ #include #include #include "grpc_problem_mapper.hpp" +#include "grpc_routing_problem_mapper.hpp" #include "grpc_service_mapper.hpp" #include "grpc_settings_mapper.hpp" #include "grpc_solution_mapper.hpp" @@ -761,6 +762,88 @@ remote_mip_result_t grpc_client_t::get_mip_result(const std::string& j return result; } +// ============================================================================= +// VRP (routing) — unary-only submit / result / solve +// ============================================================================= + +submit_result_t grpc_client_t::submit_vrp( + const cuopt::routing::cpu_routing_problem_t& problem, + const cuopt::routing::solver_settings_t& settings) +{ + submit_result_t result; + + if (!is_connected()) { + result.error_message = "Not connected to server"; + return result; + } + + cuopt::remote::SubmitJobRequest submit_request; + auto* vrp = submit_request.mutable_vrp_request(); + vrp->mutable_header()->set_version(1); + vrp->mutable_header()->set_problem_category(cuopt::remote::VRP); + map_routing_problem_to_proto(problem, vrp->mutable_problem()); + map_routing_settings_to_proto(settings, vrp->mutable_settings()); + + if (!submit_unary(submit_request, result.job_id)) { + result.error_message = last_error_; + return result; + } + result.success = true; + return result; +} + +remote_vrp_result_t grpc_client_t::get_vrp_result(const std::string& job_id) +{ + remote_vrp_result_t result; + + if (!is_connected()) { + result.error_message = "Not connected to server"; + return result; + } + + downloaded_result_t dl; + if (!get_result_or_download(job_id, dl)) { + result.error_message = last_error_; + return result; + } + if (dl.was_chunked) { + result.error_message = "chunked VRP result download is not supported"; + return result; + } + + cuopt::remote::RoutingSolution sol_pb; + if (!sol_pb.ParseFromString(dl.response->routing_solution())) { + result.error_message = "failed to parse RoutingSolution from server response"; + return result; + } + map_proto_to_routing_solution(sol_pb, result.solution); + result.success = true; + return result; +} + +remote_vrp_result_t grpc_client_t::solve_vrp( + const cuopt::routing::cpu_routing_problem_t& problem, + const cuopt::routing::solver_settings_t& settings) +{ + remote_vrp_result_t result; + + auto sub = submit_vrp(problem, settings); + if (!sub.success) { + result.error_message = sub.error_message; + return result; + } + + auto poll = poll_for_completion(sub.job_id); + if (!poll.completed) { + result.error_message = poll.error_message; + return result; + } + + result = get_vrp_result(sub.job_id); + if (result.success) { delete_job(sub.job_id); } + return result; +} + // ============================================================================= // Polling helper // ============================================================================= @@ -1067,7 +1150,8 @@ bool grpc_client_t::get_result_or_download(const std::string& job_id, auto status = impl_->stub->GetResult(&context, request, response.get()); if (status.ok() && response->status() == cuopt::remote::SUCCESS) { - if (response->has_lp_solution() || response->has_mip_solution()) { + if (response->has_lp_solution() || response->has_mip_solution() || + response->has_routing_solution()) { GRPC_CLIENT_THROUGHPUT_LOG(config_, "download_unary", response->ByteSizeLong(), download_t0); GRPC_CLIENT_DEBUG_LOG(config_, "[grpc_client] Unary GetResult succeeded, result_size=" diff --git a/cpp/src/grpc/client/grpc_client.hpp b/cpp/src/grpc/client/grpc_client.hpp index 3f8fd185de..e3eb5b0bb6 100644 --- a/cpp/src/grpc/client/grpc_client.hpp +++ b/cpp/src/grpc/client/grpc_client.hpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include "../cuopt_default_grpc_port.h" @@ -189,6 +191,16 @@ struct remote_mip_result_t { std::unique_ptr> solution; }; +/** + * @brief Result of a remote VRP solve. Routing is always , so this + * is not templated. The solution is a host-owned parse of RoutingSolution. + */ +struct remote_vrp_result_t { + bool success = false; + std::string error_message; + cuopt::routing::cpu_routing_solution_t solution; +}; + /** * @brief gRPC client for remote cuOpt solving * @@ -341,6 +353,24 @@ class grpc_client_t { template remote_mip_result_t get_mip_result(const std::string& job_id); + /** + * @brief Submit a VRP problem without waiting (unary only; no chunking yet). + * @return Submit result with job ID + */ + submit_result_t submit_vrp(const cuopt::routing::cpu_routing_problem_t& problem, + const cuopt::routing::solver_settings_t& settings); + + /** + * @brief Get the VRP result for a completed job (parses the RoutingSolution). + */ + remote_vrp_result_t get_vrp_result(const std::string& job_id); + + /** + * @brief Submit a VRP problem, wait for completion, and return the solution. + */ + remote_vrp_result_t solve_vrp(const cuopt::routing::cpu_routing_problem_t& problem, + const cuopt::routing::solver_settings_t& settings); + /** * @brief Cancel a running job * @param job_id The job ID to cancel diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/grpc_routing_problem_mapper.cpp index e758588f8e..c7cb618959 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/grpc_routing_problem_mapper.cpp @@ -331,5 +331,27 @@ void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assi } } +void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, + cuopt::routing::cpu_routing_solution_t& sol) +{ + copy_repeated_to_vector(pb.route(), sol.route); + copy_repeated_to_vector(pb.arrival_stamp(), sol.arrival_stamp); + copy_repeated_to_vector(pb.truck_id(), sol.truck_id); + copy_repeated_to_vector(pb.locations(), sol.locations); + copy_repeated_to_vector(pb.node_types(), sol.node_types); + copy_repeated_to_vector(pb.unserviced_nodes(), sol.unserviced_nodes); + copy_repeated_to_vector(pb.accepted(), sol.accepted); + + sol.vehicle_count = pb.vehicle_count(); + sol.total_objective_value = pb.total_objective_value(); + sol.objective_values.clear(); + for (auto const& [obj, value] : pb.objective_values()) { + sol.objective_values[static_cast(obj)] = value; + } + sol.status = static_cast(pb.status()); + sol.status_message = pb.status_message(); + sol.error_message = pb.error_message(); +} + } // namespace mathematical_optimization } // namespace cuopt diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/grpc_routing_problem_mapper.hpp index f061f37528..ceee3481e1 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.hpp +++ b/cpp/src/grpc/grpc_routing_problem_mapper.hpp @@ -32,5 +32,9 @@ void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assi const cuopt::routing::host_assignment_t& host, cuopt::remote::RoutingSolution* pb); +// Client direction: parse a RoutingSolution proto into a host solution struct. +void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, + cuopt::routing::cpu_routing_solution_t& sol); + } // namespace mathematical_optimization } // namespace cuopt diff --git a/python/cuopt/cuopt/grpc/CMakeLists.txt b/python/cuopt/cuopt/grpc/CMakeLists.txt index 4d896ee319..0f3c37f565 100644 --- a/python/cuopt/cuopt/grpc/CMakeLists.txt +++ b/python/cuopt/cuopt/grpc/CMakeLists.txt @@ -4,3 +4,4 @@ # cmake-format: on add_subdirectory(linear_programming) +add_subdirectory(routing) diff --git a/python/cuopt/cuopt/grpc/routing/CMakeLists.txt b/python/cuopt/cuopt/grpc/routing/CMakeLists.txt new file mode 100644 index 0000000000..620e038de2 --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/CMakeLists.txt @@ -0,0 +1,15 @@ +# cmake-format: off +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# cmake-format: on + +set(cython_sources grpc_client.pyx) +set(linked_libraries cuopt::cuopt) + +rapids_cython_create_modules( + CXX + SOURCE_FILES "${cython_sources}" + LINKED_LIBRARIES "${linked_libraries}" + MODULE_PREFIX grpc_routing_ + ASSOCIATED_TARGETS cuopt +) diff --git a/python/cuopt/cuopt/grpc/routing/__init__.py b/python/cuopt/cuopt/grpc/routing/__init__.py new file mode 100644 index 0000000000..9be5a4e525 --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/__init__.py @@ -0,0 +1,21 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compiled gRPC client for remote VRP (vehicle routing) solves. + +Build a :class:`cuopt.routing.DataModel` and solve it on a remote +``cuopt_grpc_server``:: + + from cuopt import routing + from cuopt.grpc.routing import RoutingClient + + dm = routing.DataModel(n_locations, n_fleet) + dm.add_cost_matrix(cost) + ... + client = RoutingClient("gpu-host:50051") + solution = client.solve(dm) +""" + +from cuopt.grpc.routing.grpc_client import RoutingClient, RoutingSolveError + +__all__ = ["RoutingClient", "RoutingSolveError"] diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd new file mode 100644 index 0000000000..ed98188caa --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from libc.stdint cimport int32_t, uint8_t +from libcpp cimport bool +from libcpp.map cimport map as cpp_map +from libcpp.memory cimport unique_ptr +from libcpp.string cimport string +from libcpp.vector cimport vector + + +cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routing": # noqa + cdef cppclass cpu_cost_matrix_t: + cpu_cost_matrix_t() except + + uint8_t vehicle_type + vector[float] matrix + + cdef cppclass cpu_capacity_dimension_t: + cpu_capacity_dimension_t() except + + string name + vector[int32_t] demand + vector[int32_t] capacity + + cdef cppclass cpu_uniform_break_t: + cpu_uniform_break_t() except + + vector[int32_t] earliest + vector[int32_t] latest + vector[int32_t] duration + + cdef cppclass cpu_vehicle_break_t: + cpu_vehicle_break_t() except + + int32_t earliest + int32_t latest + int32_t duration + vector[int32_t] locations + + cdef cppclass cpu_initial_solution_t: + cpu_initial_solution_t() except + + vector[int32_t] vehicle_ids + vector[int32_t] routes + vector[int32_t] types + vector[int32_t] sol_offsets + + cdef cppclass cpu_routing_problem_t: + int32_t num_locations + int32_t fleet_size + int32_t num_orders + vector[cpu_cost_matrix_t] cost_matrices + vector[cpu_cost_matrix_t] transit_time_matrices + vector[int32_t] vehicle_start_locations + vector[int32_t] vehicle_return_locations + vector[int32_t] vehicle_tw_earliest + vector[int32_t] vehicle_tw_latest + vector[uint8_t] vehicle_types + vector[uint8_t] drop_return_trips + vector[uint8_t] skip_first_trips + vector[float] vehicle_max_costs + vector[float] vehicle_max_times + vector[float] vehicle_fixed_costs + vector[int32_t] order_locations + vector[int32_t] order_tw_earliest + vector[int32_t] order_tw_latest + vector[float] order_prizes + cpp_map[int32_t, vector[int32_t]] order_service_times + vector[int32_t] pickup_indices + vector[int32_t] delivery_indices + vector[cpu_capacity_dimension_t] capacity_dimensions + vector[int32_t] break_locations + vector[cpu_uniform_break_t] uniform_breaks + cpp_map[int32_t, vector[cpu_vehicle_break_t]] vehicle_breaks + cpp_map[int32_t, vector[int32_t]] vehicle_order_match + cpp_map[int32_t, vector[int32_t]] order_vehicle_match + cpp_map[int32_t, vector[int32_t]] order_precedence + vector[int32_t] objectives + vector[float] objective_weights + int32_t min_vehicles + cpu_initial_solution_t initial_solutions + + cdef cppclass cpu_routing_solution_t: + vector[int32_t] route + vector[double] arrival_stamp + vector[int32_t] truck_id + vector[int32_t] locations + vector[int32_t] node_types + vector[int32_t] unserviced_nodes + vector[int32_t] accepted + int32_t vehicle_count + double total_objective_value + cpp_map[int32_t, double] objective_values + int32_t status + string status_message + string error_message + + +cdef extern from "cuopt/routing/solver_settings.hpp" namespace "cuopt::routing": # noqa + cdef cppclass solver_settings_t[i_t, f_t]: + solver_settings_t() except + + void set_time_limit(f_t seconds) except + + void set_verbose_mode(bool verbose) except + + void set_error_logging_mode(bool logging) except + + + +cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": # noqa + ctypedef enum grpc_job_status_t "cuopt::cython::grpc_job_status_t": + QUEUED "cuopt::cython::grpc_job_status_t::QUEUED" + PROCESSING "cuopt::cython::grpc_job_status_t::PROCESSING" + COMPLETED "cuopt::cython::grpc_job_status_t::COMPLETED" + FAILED "cuopt::cython::grpc_job_status_t::FAILED" + CANCELLED "cuopt::cython::grpc_job_status_t::CANCELLED" + NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" + + ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": + ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" + DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" + + cdef cppclass grpc_python_client_connect_options_t: + grpc_python_tls_mode_t tls_mode + string tls_root_certs + string tls_client_cert + string tls_client_key + + cdef cppclass grpc_submit_result_t: + bool success + string error_message + string job_id + bool is_mip + + cdef cppclass grpc_status_result_t: + bool success + string error_message + grpc_job_status_t status + string message + long long result_size_bytes + + cdef cppclass grpc_vrp_result_outcome_t: + bool not_ready + bool success + string error_message + cpu_routing_solution_t solution + + cdef cppclass grpc_python_client_t: + grpc_python_client_t(const string& host, int port) except + + bool connect(string& error_out) except + + grpc_submit_result_t submit_vrp( + cpu_routing_problem_t* problem, + solver_settings_t[int, float]* settings) except + + grpc_status_result_t status(const string& job_id) except + + grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + + grpc_vrp_result_outcome_t result_vrp(const string& job_id) except + + bool delete_job(const string& job_id, string& error_out) except + + string last_error() except + diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx new file mode 100644 index 0000000000..841f479a29 --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx @@ -0,0 +1,318 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# distutils: language = c++ + +"""Compiled gRPC client for remote VRP solves, consistent with the LP/MILP +client. Builds a host ``cpu_routing_problem_t`` from a routing ``DataModel`` +(walking its store-then-build IR) and serializes it in C++; no pure-Python +protobuf path. +""" + +from cython.operator cimport dereference as deref, postincrement as postinc +from libc.stdint cimport int32_t, uint8_t +from libcpp.map cimport map as cpp_map +from libcpp.memory cimport unique_ptr +from libcpp.string cimport string +from libcpp.vector cimport vector + +import numpy as np + +from cuopt.grpc.routing.grpc_client cimport ( + COMPLETED, + cpu_capacity_dimension_t, + cpu_cost_matrix_t, + cpu_routing_problem_t, + cpu_routing_solution_t, + cpu_uniform_break_t, + cpu_vehicle_break_t, + grpc_python_client_t, + grpc_status_result_t, + grpc_submit_result_t, + grpc_vrp_result_outcome_t, + solver_settings_t, +) + + +class RoutingSolveError(RuntimeError): + """A remote VRP job failed or returned no routing solution.""" + + +def _to_host(x): + """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" + if isinstance(x, np.ndarray): + return x + root = type(x).__module__.split(".", 1)[0] + if root in ("pandas", "cudf"): + return x.to_numpy() + if root == "cupy": + return x.get() + return np.asarray(x) + + +# --- numpy -> std::vector fillers ------------------------------------------ + +cdef void _fill_i32(vector[int32_t]& v, arr) except *: + cdef int32_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.int32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_u8(vector[uint8_t]& v, arr) except *: + cdef uint8_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.uint8).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_f32(vector[float]& v, arr) except *: + cdef float[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.float32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +# --- std::vector -> numpy --------------------------------------------------- + +cdef _i32_to_np(const vector[int32_t]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.int32) + cdef int32_t[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +cdef _f64_to_np(const vector[double]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.float64) + cdef double[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +# --- DataModel IR -> cpu_routing_problem_t ---------------------------------- + +cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: + cdef cpu_cost_matrix_t cm + mat = _to_host(args[0]).astype(np.float32, copy=False) + cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) + _fill_f32(cm.matrix, np.ascontiguousarray(mat).ravel(order="C")) + dst.push_back(cm) + + +cdef void _populate(cpu_routing_problem_t& p, data_model) except *: + n_loc, fleet, n_ord = data_model._init_args + p.num_locations = int(n_loc) + p.fleet_size = int(fleet) + p.num_orders = (int(n_loc) if int(n_ord) == -1 else int(n_ord)) + + cdef vector[int32_t] tmp_i + cdef cpu_capacity_dimension_t cap + cdef cpu_uniform_break_t ub + cdef cpu_vehicle_break_t vb + cdef int32_t vid + + for name, args, _ in data_model._calls: + if name == "add_cost_matrix": + _add_matrix(p.cost_matrices, args) + elif name == "add_transit_time_matrix": + _add_matrix(p.transit_time_matrices, args) + elif name == "set_order_time_windows": + _fill_i32(p.order_tw_earliest, args[0]) + _fill_i32(p.order_tw_latest, args[1]) + elif name == "set_vehicle_time_windows": + _fill_i32(p.vehicle_tw_earliest, args[0]) + _fill_i32(p.vehicle_tw_latest, args[1]) + elif name == "set_vehicle_locations": + _fill_i32(p.vehicle_start_locations, args[0]) + _fill_i32(p.vehicle_return_locations, args[1]) + elif name == "set_pickup_delivery_pairs": + _fill_i32(p.pickup_indices, args[0]) + _fill_i32(p.delivery_indices, args[1]) + elif name == "add_capacity_dimension": + cap = cpu_capacity_dimension_t() + cap.name = str(args[0]).encode("utf-8") + _fill_i32(cap.demand, args[1]) + _fill_i32(cap.capacity, args[2]) + p.capacity_dimensions.push_back(cap) + elif name == "set_order_service_times": + vid = (int(args[1]) if len(args) > 1 else -1) + tmp_i.clear() + _fill_i32(tmp_i, args[0]) + p.order_service_times[vid] = tmp_i + elif name == "add_vehicle_order_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.vehicle_order_match[vid] = tmp_i + elif name == "add_order_vehicle_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_vehicle_match[vid] = tmp_i + elif name == "add_order_precedence": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_precedence[vid] = tmp_i + elif name == "add_break_dimension": + ub = cpu_uniform_break_t() + _fill_i32(ub.earliest, args[0]) + _fill_i32(ub.latest, args[1]) + _fill_i32(ub.duration, args[2]) + p.uniform_breaks.push_back(ub) + elif name == "add_vehicle_break": + vid = int(args[0]) + vb = cpu_vehicle_break_t() + vb.earliest = int(args[1]) + vb.latest = int(args[2]) + vb.duration = int(args[3]) + if len(args) > 4 and args[4] is not None: + _fill_i32(vb.locations, args[4]) + p.vehicle_breaks[vid].push_back(vb) + elif name == "set_objective_function": + _fill_i32(p.objectives, args[0]) + _fill_f32(p.objective_weights, args[1]) + elif name == "add_initial_solutions": + _fill_i32(p.initial_solutions.vehicle_ids, args[0]) + _fill_i32(p.initial_solutions.routes, args[1]) + _fill_i32(p.initial_solutions.types, args[2]) + _fill_i32(p.initial_solutions.sol_offsets, args[3]) + elif name == "set_min_vehicles": + p.min_vehicles = int(args[0]) + elif name == "set_order_locations": + _fill_i32(p.order_locations, args[0]) + elif name == "set_order_prizes": + _fill_f32(p.order_prizes, args[0]) + elif name == "set_vehicle_types": + _fill_u8(p.vehicle_types, args[0]) + elif name == "set_drop_return_trips": + _fill_u8(p.drop_return_trips, args[0]) + elif name == "set_skip_first_trips": + _fill_u8(p.skip_first_trips, args[0]) + elif name == "set_vehicle_max_costs": + _fill_f32(p.vehicle_max_costs, args[0]) + elif name == "set_vehicle_max_times": + _fill_f32(p.vehicle_max_times, args[0]) + elif name == "set_vehicle_fixed_costs": + _fill_f32(p.vehicle_fixed_costs, args[0]) + elif name == "set_break_locations": + _fill_i32(p.break_locations, args[0]) + else: + raise KeyError( + f"no VRP gRPC mapping for recorded setter {name!r}; add a case " + "to cuopt.grpc.routing.grpc_client._populate" + ) + + +cdef _solution_to_py(cpu_routing_solution_t s): + cdef dict objectives = {} + cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() + while it != s.objective_values.end(): + objectives[int(deref(it).first)] = float(deref(it).second) + postinc(it) + return { + "status": int(s.status), + "status_message": s.status_message.decode("utf-8"), + "error_message": s.error_message.decode("utf-8"), + "vehicle_count": int(s.vehicle_count), + "total_objective_value": float(s.total_objective_value), + "objective_values": objectives, + "route": _i32_to_np(s.route), + "truck_id": _i32_to_np(s.truck_id), + "locations": _i32_to_np(s.locations), + "node_types": _i32_to_np(s.node_types), + "arrival_stamp": _f64_to_np(s.arrival_stamp), + "unserviced_nodes": _i32_to_np(s.unserviced_nodes), + "accepted": _i32_to_np(s.accepted), + } + + +cdef class RoutingClient: + """Client for solving VRP problems on a remote cuOpt gRPC server.""" + + cdef unique_ptr[grpc_python_client_t] _client + + def __cinit__(self, str target="localhost:50051"): + host, _, port = target.rpartition(":") + if not host: + host, port = target, "50051" + cdef string host_cpp = host.encode("utf-8") + cdef string err + self._client.reset(new grpc_python_client_t(host_cpp, int(port))) + if not self._client.get().connect(err): + raise RoutingSolveError( + "failed to connect: " + err.decode("utf-8") + ) + + cdef _apply_settings(self, solver_settings_t[int, float]& s, settings): + if settings is None: + return + if isinstance(settings, dict): + tl = settings.get("time_limit") + if tl: + s.set_time_limit(float(tl)) + return + get_time_limit = getattr(settings, "get_time_limit", None) + if get_time_limit is not None: + tl = get_time_limit() + if tl: + s.set_time_limit(float(tl)) + + def submit(self, data_model, settings=None): + """Serialize and submit a VRP problem; return its ``job_id``.""" + cdef cpu_routing_problem_t problem + cdef solver_settings_t[int, float] cpp_settings + _populate(problem, data_model) + self._apply_settings(cpp_settings, settings) + cdef grpc_submit_result_t sub = self._client.get().submit_vrp( + &problem, &cpp_settings + ) + if not sub.success: + raise RoutingSolveError(sub.error_message.decode("utf-8")) + return sub.job_id.decode("utf-8") + + def wait(self, str job_id, int timeout=0): + """Block until the job finishes; return the terminal status int.""" + cdef grpc_status_result_t st = self._client.get().wait( + job_id.encode("utf-8"), timeout + ) + return st.status + + def result(self, str job_id): + """Fetch and parse the routing solution for a completed job.""" + cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( + job_id.encode("utf-8") + ) + if not out.success: + raise RoutingSolveError(out.error_message.decode("utf-8")) + return _solution_to_py(out.solution) + + def delete(self, str job_id): + cdef string err + self._client.get().delete_job(job_id.encode("utf-8"), err) + + def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): + """Submit, wait, and return the solution (the common path).""" + job_id = self.submit(data_model, settings) + status = self.wait(job_id, timeout) + if status != COMPLETED: + raise RoutingSolveError( + f"job {job_id} did not complete (status {status})" + ) + try: + return self.result(job_id) + finally: + if delete: + self.delete(job_id) diff --git a/python/cuopt/cuopt/tests/routing/test_grpc_client.py b/python/cuopt/cuopt/tests/routing/test_grpc_client.py new file mode 100644 index 0000000000..5de3fec0cc --- /dev/null +++ b/python/cuopt/cuopt/tests/routing/test_grpc_client.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Integration test for the compiled VRP gRPC client (cuopt.grpc.routing). + +Skipped unless ``CUOPT_GRPC_SERVER`` points at a running cuopt_grpc_server that +supports VRP (``host:port``). Run locally with, e.g.:: + + cuopt_grpc_server --port 50051 & + CUOPT_GRPC_SERVER=localhost:50051 pytest test_grpc_client.py +""" + +import os + +import numpy as np +import pytest + +from cuopt import routing + +grpc_routing = pytest.importorskip("cuopt.grpc.routing") + +_SERVER = os.environ.get("CUOPT_GRPC_SERVER") +pytestmark = pytest.mark.skipif( + not _SERVER, reason="set CUOPT_GRPC_SERVER=host:port to run VRP gRPC tests" +) + + +def _small_vrp(): + dm = routing.DataModel(5, 2) + cost = np.array( + [ + [0, 1, 2, 2, 1], + [1, 0, 1, 2, 2], + [2, 1, 0, 1, 2], + [2, 2, 1, 0, 1], + [1, 2, 2, 1, 0], + ], + dtype=np.float32, + ) + dm.add_cost_matrix(cost) + return dm + + +def test_remote_solve_matches_local(): + settings = routing.SolverSettings() + settings.set_time_limit(2) + local = routing.Solve(_small_vrp(), settings) + + client = grpc_routing.RoutingClient(_SERVER) + remote = client.solve(_small_vrp(), {"time_limit": 2.0}) + + assert remote["status"] == 0, remote["status_message"] + assert remote["vehicle_count"] >= 1 + assert remote["total_objective_value"] == pytest.approx( + local.get_total_objective(), rel=0.2 + ) + + +def test_submit_wait_result_lifecycle(): + client = grpc_routing.RoutingClient(_SERVER) + job_id = client.submit(_small_vrp(), {"time_limit": 1.0}) + assert job_id + client.wait(job_id, timeout=30) + solution = client.result(job_id) + assert "route" in solution + client.delete(job_id) From bca7e019cd821c393498441a93f260c17f89608b Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 12:15:08 -0500 Subject: [PATCH 04/17] Add VRP client serialization coverage tests + probe problem_summary() runs the exact _populate path without a server; the tests assert every cuopt.routing._deferred._SETTERS name is mapped (fail-loud on a new setter), exercise each field category, cover numpy/pandas/cuDF inputs, and check the fail-loud KeyError on an unmapped setter. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- .../cuopt/cuopt/grpc/routing/grpc_client.pyx | 77 ++++++++ .../tests/routing/test_grpc_serialization.py | 177 ++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 python/cuopt/cuopt/tests/routing/test_grpc_serialization.py diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx index 841f479a29..e46cf2b595 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx @@ -38,6 +38,38 @@ class RoutingSolveError(RuntimeError): """A remote VRP job failed or returned no routing solution.""" +# Recorded setters that _populate() maps into cpu_routing_problem_t. Must match +# the dispatch in _populate; test_grpc_serialization asserts this covers every +# name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. +HANDLED_SETTERS = frozenset({ + "add_cost_matrix", + "add_transit_time_matrix", + "set_order_time_windows", + "set_vehicle_time_windows", + "set_vehicle_locations", + "set_pickup_delivery_pairs", + "add_capacity_dimension", + "set_order_service_times", + "add_vehicle_order_match", + "add_order_vehicle_match", + "add_order_precedence", + "add_break_dimension", + "add_vehicle_break", + "set_objective_function", + "add_initial_solutions", + "set_min_vehicles", + "set_order_locations", + "set_order_prizes", + "set_vehicle_types", + "set_drop_return_trips", + "set_skip_first_trips", + "set_vehicle_max_costs", + "set_vehicle_max_times", + "set_vehicle_fixed_costs", + "set_break_locations", +}) + + def _to_host(x): """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" if isinstance(x, np.ndarray): @@ -216,6 +248,51 @@ cdef void _populate(cpu_routing_problem_t& p, data_model) except *: ) +def problem_summary(data_model): + """Populate a ``cpu_routing_problem_t`` from ``data_model`` and return a + ``{field: size}`` summary. Runs the exact ``_populate`` path used by + ``submit`` (so a mis-mapped or unmapped setter fails here too), without a + server. Intended for tests. + """ + cdef cpu_routing_problem_t p + _populate(p, data_model) + return { + "num_locations": int(p.num_locations), + "fleet_size": int(p.fleet_size), + "num_orders": int(p.num_orders), + "min_vehicles": int(p.min_vehicles), + "cost_matrices": p.cost_matrices.size(), + "transit_time_matrices": p.transit_time_matrices.size(), + "vehicle_start_locations": p.vehicle_start_locations.size(), + "vehicle_return_locations": p.vehicle_return_locations.size(), + "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), + "vehicle_tw_latest": p.vehicle_tw_latest.size(), + "vehicle_types": p.vehicle_types.size(), + "drop_return_trips": p.drop_return_trips.size(), + "skip_first_trips": p.skip_first_trips.size(), + "vehicle_max_costs": p.vehicle_max_costs.size(), + "vehicle_max_times": p.vehicle_max_times.size(), + "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), + "order_locations": p.order_locations.size(), + "order_tw_earliest": p.order_tw_earliest.size(), + "order_tw_latest": p.order_tw_latest.size(), + "order_prizes": p.order_prizes.size(), + "order_service_times": p.order_service_times.size(), + "pickup_indices": p.pickup_indices.size(), + "delivery_indices": p.delivery_indices.size(), + "capacity_dimensions": p.capacity_dimensions.size(), + "break_locations": p.break_locations.size(), + "uniform_breaks": p.uniform_breaks.size(), + "vehicle_breaks": p.vehicle_breaks.size(), + "vehicle_order_match": p.vehicle_order_match.size(), + "order_vehicle_match": p.order_vehicle_match.size(), + "order_precedence": p.order_precedence.size(), + "objectives": p.objectives.size(), + "objective_weights": p.objective_weights.size(), + "initial_solutions_routes": p.initial_solutions.routes.size(), + } + + cdef _solution_to_py(cpu_routing_solution_t s): cdef dict objectives = {} cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() diff --git a/python/cuopt/cuopt/tests/routing/test_grpc_serialization.py b/python/cuopt/cuopt/tests/routing/test_grpc_serialization.py new file mode 100644 index 0000000000..fa43287ee2 --- /dev/null +++ b/python/cuopt/cuopt/tests/routing/test_grpc_serialization.py @@ -0,0 +1,177 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Coverage + unit tests for the VRP gRPC client serialization (no server). + +These exercise the exact ``_populate`` path used by ``RoutingClient.submit`` via +the ``problem_summary`` probe, so a setter that is added to the DataModel but not +mapped by the client fails loudly here. +""" + +import numpy as np +import pytest + +from cuopt import routing +from cuopt.routing import _deferred + +grpc_client = pytest.importorskip("cuopt.grpc.routing.grpc_client") +HANDLED_SETTERS = grpc_client.HANDLED_SETTERS +problem_summary = grpc_client.problem_summary + + +def test_client_maps_every_recordable_setter(): + """Every setter the deferred DataModel can record must be mapped by the + gRPC client. Fails loudly if a new setter is added without a mapping. + """ + missing = set(_deferred._SETTERS) - HANDLED_SETTERS + assert not missing, ( + f"gRPC client _populate does not map recorded setters {sorted(missing)}; " + "add a case to grpc_client._populate and to HANDLED_SETTERS" + ) + # and no stale names that are not real setters + stale = HANDLED_SETTERS - set(_deferred._SETTERS) + assert not stale, f"HANDLED_SETTERS lists non-setters {sorted(stale)}" + + +def test_populate_scalar_matrix_and_dimension_fields(): + dm = routing.DataModel(5, 2, 5) + cost = np.ones((5, 5), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost, 0) + dm.add_cost_matrix(cost * 2, 1) + dm.add_transit_time_matrix(cost, 0) + dm.set_vehicle_time_windows( + np.zeros(2, np.int32), np.full(2, 100, np.int32) + ) + dm.set_order_time_windows(np.zeros(5, np.int32), np.full(5, 50, np.int32)) + dm.set_order_locations(np.arange(5, dtype=np.int32)) + dm.set_order_prizes(np.ones(5, np.float32)) + dm.set_order_service_times(np.ones(5, np.int32)) + dm.add_capacity_dimension( + "w", np.ones(5, np.int32), np.full(2, 10, np.int32) + ) + dm.set_vehicle_types(np.array([0, 1], np.uint8)) + dm.set_vehicle_max_costs(np.full(2, 99.0, np.float32)) + dm.set_vehicle_max_times(np.full(2, 99.0, np.float32)) + dm.set_objective_function( + np.array([0], np.int32), np.array([1.0], np.float32) + ) + dm.set_min_vehicles(1) + + s = problem_summary(dm) + assert (s["num_locations"], s["fleet_size"], s["num_orders"]) == (5, 2, 5) + assert s["cost_matrices"] == 2 + assert s["transit_time_matrices"] == 1 + assert s["vehicle_tw_latest"] == 2 + assert s["order_tw_latest"] == 5 + assert s["order_locations"] == 5 + assert s["order_prizes"] == 5 + assert s["order_service_times"] == 1 # one (default) vehicle_id entry + assert s["capacity_dimensions"] == 1 + assert s["vehicle_types"] == 2 + assert s["vehicle_max_costs"] == 2 + assert s["objectives"] == 1 + assert s["min_vehicles"] == 1 + + +def test_populate_pickup_delivery(): + dm = routing.DataModel(5, 2, 4) + cost = np.ones((5, 5), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost) + dm.set_pickup_delivery_pairs( + np.array([1, 2], np.int32), np.array([3, 4], np.int32) + ) + s = problem_summary(dm) + assert s["pickup_indices"] == 2 + assert s["delivery_indices"] == 2 + + +def test_populate_matches_and_precedence(): + dm = routing.DataModel(4, 2) + cost = np.ones((4, 4), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost) + dm.add_vehicle_order_match(0, np.array([1, 2], np.int32)) + dm.add_order_vehicle_match(1, np.array([0], np.int32)) + dm.add_order_precedence(2, np.array([1], np.int32)) + s = problem_summary(dm) + assert s["vehicle_order_match"] == 1 + assert s["order_vehicle_match"] == 1 + assert s["order_precedence"] == 1 + + +def test_populate_breaks(): + dm = routing.DataModel(4, 2) + cost = np.ones((4, 4), dtype=np.float32) + np.fill_diagonal(cost, 0) + dm.add_cost_matrix(cost) + dm.add_break_dimension( + np.full(2, 10, np.int32), + np.full(2, 20, np.int32), + np.full(2, 5, np.int32), + ) + s = problem_summary(dm) + assert s["uniform_breaks"] == 1 + + +def test_populate_handles_pandas_host_inputs(): + """Pandas (host) inputs map identically to numpy. + + This is the natural input for a GPU-less client. + """ + pd = pytest.importorskip("pandas") + cost = np.ones((4, 4), dtype=np.float32) + np.fill_diagonal(cost, 0) + + dm_np = routing.DataModel(4, 2) + dm_np.add_cost_matrix(cost) + dm_np.set_vehicle_time_windows( + np.zeros(2, np.int32), np.full(2, 100, np.int32) + ) + dm_np.add_capacity_dimension( + "w", np.array([0, 1, 1, 1], np.int32), np.array([3, 3], np.int32) + ) + + dm_pd = routing.DataModel(4, 2) + dm_pd.add_cost_matrix(pd.DataFrame(cost)) + dm_pd.set_vehicle_time_windows(pd.Series([0, 0]), pd.Series([100, 100])) + dm_pd.add_capacity_dimension( + "w", pd.Series([0, 1, 1, 1]), pd.Series([3, 3]) + ) + + assert problem_summary(dm_np) == problem_summary(dm_pd) + + +def test_populate_handles_cudf_device_inputs(): + """Device (cuDF) inputs are copied to host by _populate, matching numpy.""" + cudf = pytest.importorskip("cudf") + cost = np.ones((4, 4), dtype=np.float32) + np.fill_diagonal(cost, 0) + + dm_np = routing.DataModel(4, 2) + dm_np.add_cost_matrix(cost) + dm_np.add_capacity_dimension( + "w", np.array([0, 1, 1, 1], np.int32), np.array([3, 3], np.int32) + ) + + dm_cudf = routing.DataModel(4, 2) + dm_cudf.add_cost_matrix(cost) + dm_cudf.add_capacity_dimension( + "w", + cudf.Series([0, 1, 1, 1], dtype="int32"), + cudf.Series([3, 3], dtype="int32"), + ) + + assert problem_summary(dm_np) == problem_summary(dm_cudf) + + +def test_unmapped_setter_raises(): + """A recorded setter with no mapping fails loudly (mirrors the fail-loud + contract), rather than silently dropping data on the wire. + """ + dm = routing.DataModel(3, 1) + dm.add_cost_matrix(np.eye(3, dtype=np.float32)) + dm._calls.append(("set_made_up_field", (np.array([1, 2, 3]),), {})) + with pytest.raises(KeyError, match="set_made_up_field"): + problem_summary(dm) From a329c6caea5ef3e92a417e8bf58fd3519ea6a071 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 12:45:54 -0500 Subject: [PATCH 05/17] Simplify VRP client: drop dead solve_vrp + unused decls - Remove grpc_client_t::solve_vrp: the Cython client drives submit/wait/result through the shim, so the C++ one-shot convenience had no caller. - Trim unused .pxd declarations (TLS options, status(), last_error(), and unused result fields) to only what the client uses. - _add_matrix: drop the redundant to_host/astype/ascontiguousarray/ravel that _fill_f32 already performs. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/client/grpc_client.cpp | 23 ------------------- cpp/src/grpc/client/grpc_client.hpp | 6 ----- .../cuopt/cuopt/grpc/routing/grpc_client.pxd | 18 --------------- .../cuopt/cuopt/grpc/routing/grpc_client.pyx | 4 ++-- 4 files changed, 2 insertions(+), 49 deletions(-) diff --git a/cpp/src/grpc/client/grpc_client.cpp b/cpp/src/grpc/client/grpc_client.cpp index d45b21da39..29ad1fae23 100644 --- a/cpp/src/grpc/client/grpc_client.cpp +++ b/cpp/src/grpc/client/grpc_client.cpp @@ -821,29 +821,6 @@ remote_vrp_result_t grpc_client_t::get_vrp_result(const std::string& job_id) return result; } -remote_vrp_result_t grpc_client_t::solve_vrp( - const cuopt::routing::cpu_routing_problem_t& problem, - const cuopt::routing::solver_settings_t& settings) -{ - remote_vrp_result_t result; - - auto sub = submit_vrp(problem, settings); - if (!sub.success) { - result.error_message = sub.error_message; - return result; - } - - auto poll = poll_for_completion(sub.job_id); - if (!poll.completed) { - result.error_message = poll.error_message; - return result; - } - - result = get_vrp_result(sub.job_id); - if (result.success) { delete_job(sub.job_id); } - return result; -} - // ============================================================================= // Polling helper // ============================================================================= diff --git a/cpp/src/grpc/client/grpc_client.hpp b/cpp/src/grpc/client/grpc_client.hpp index e3eb5b0bb6..8fe2d08c01 100644 --- a/cpp/src/grpc/client/grpc_client.hpp +++ b/cpp/src/grpc/client/grpc_client.hpp @@ -365,12 +365,6 @@ class grpc_client_t { */ remote_vrp_result_t get_vrp_result(const std::string& job_id); - /** - * @brief Submit a VRP problem, wait for completion, and return the solution. - */ - remote_vrp_result_t solve_vrp(const cuopt::routing::cpu_routing_problem_t& problem, - const cuopt::routing::solver_settings_t& settings); - /** * @brief Cancel a running job * @param job_id The job ID to cancel diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd index ed98188caa..1241d91f44 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd @@ -109,31 +109,15 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": CANCELLED "cuopt::cython::grpc_job_status_t::CANCELLED" NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" - ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": - ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" - DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" - - cdef cppclass grpc_python_client_connect_options_t: - grpc_python_tls_mode_t tls_mode - string tls_root_certs - string tls_client_cert - string tls_client_key - cdef cppclass grpc_submit_result_t: bool success string error_message string job_id - bool is_mip cdef cppclass grpc_status_result_t: - bool success - string error_message grpc_job_status_t status - string message - long long result_size_bytes cdef cppclass grpc_vrp_result_outcome_t: - bool not_ready bool success string error_message cpu_routing_solution_t solution @@ -144,8 +128,6 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": grpc_submit_result_t submit_vrp( cpu_routing_problem_t* problem, solver_settings_t[int, float]* settings) except + - grpc_status_result_t status(const string& job_id) except + grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + grpc_vrp_result_outcome_t result_vrp(const string& job_id) except + bool delete_job(const string& job_id, string& error_out) except + - string last_error() except + diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx index e46cf2b595..bc30b88f4c 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx @@ -136,10 +136,10 @@ cdef _f64_to_np(const vector[double]& v): # --- DataModel IR -> cpu_routing_problem_t ---------------------------------- cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: + # _fill_f32 already casts to float32 and C-order ravels (row-major). cdef cpu_cost_matrix_t cm - mat = _to_host(args[0]).astype(np.float32, copy=False) cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) - _fill_f32(cm.matrix, np.ascontiguousarray(mat).ravel(order="C")) + _fill_f32(cm.matrix, args[0]) dst.push_back(cm) From 002c971f05994821bee7afdbefd5b993d8ed9e9c Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 13:01:51 -0500 Subject: [PATCH 06/17] Address review: honor time_limit=0; result_vrp not_ready pre-check - _apply_settings: use `is not None` so an explicit time_limit=0 is applied rather than silently dropped (0 is falsy). - result_vrp: add the in-flight status pre-check that result() does, so polling result() on a running job returns a structured not_ready signal (RoutingClient .result() now returns None) instead of a generic GetResult failure. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/client/cython_grpc_client.cpp | 13 +++++++++++++ python/cuopt/cuopt/grpc/routing/grpc_client.pxd | 1 + python/cuopt/cuopt/grpc/routing/grpc_client.pyx | 11 ++++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cpp/src/grpc/client/cython_grpc_client.cpp b/cpp/src/grpc/client/cython_grpc_client.cpp index f12d8af88b..b7195f2353 100644 --- a/cpp/src/grpc/client/cython_grpc_client.cpp +++ b/cpp/src/grpc/client/cython_grpc_client.cpp @@ -171,6 +171,19 @@ grpc_submit_result_t grpc_python_client_t::submit_vrp( grpc_vrp_result_outcome_t grpc_python_client_t::result_vrp(const std::string& job_id) { grpc_vrp_result_outcome_t out; + + // Mirror result(): surface a structured "still running" signal instead of a + // generic GetResult failure when a caller polls result_vrp on an in-flight job. + auto st = status(job_id); + if (!st.success) { + out.error_message = st.error_message; + return out; + } + if (is_in_flight(st.status)) { + out.not_ready = true; + return out; + } + auto remote = impl_->client.get_vrp_result(job_id); if (!remote.success) { out.error_message = remote.error_message; diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd index 1241d91f44..748643c3ca 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd @@ -118,6 +118,7 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": grpc_job_status_t status cdef cppclass grpc_vrp_result_outcome_t: + bool not_ready bool success string error_message cpu_routing_solution_t solution diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx index bc30b88f4c..6b0fa031cb 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx @@ -338,13 +338,13 @@ cdef class RoutingClient: return if isinstance(settings, dict): tl = settings.get("time_limit") - if tl: + if tl is not None: s.set_time_limit(float(tl)) return get_time_limit = getattr(settings, "get_time_limit", None) if get_time_limit is not None: tl = get_time_limit() - if tl: + if tl is not None: s.set_time_limit(float(tl)) def submit(self, data_model, settings=None): @@ -368,10 +368,15 @@ cdef class RoutingClient: return st.status def result(self, str job_id): - """Fetch and parse the routing solution for a completed job.""" + """Fetch and parse the routing solution for a completed job. + + Returns ``None`` if the job is still in flight (mirrors the LP client). + """ cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( job_id.encode("utf-8") ) + if out.not_ready: + return None if not out.success: raise RoutingSolveError(out.error_message.decode("utf-8")) return _solution_to_py(out.solution) From 7a61e650fc81d4a02a3e6cb6764a93f2c25b043c Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 21 Jul 2026 13:40:43 -0500 Subject: [PATCH 07/17] Address review: sanitize VRP error; fix to_device host-buffer lifetime + validate capacity sizes - map_routing_solution_to_proto: run get_error_status() through a sanitizer (mirrors format_cuopt_error) so raw internal error text is not sent to remote clients, without pulling the heavy server header into the client-linked mapper. - to_device: drain the stream before the transient host buffers in copy_u8_as_bool() and the initial-solutions types conversion go out of scope, so their async H2D copies do not read freed memory. - to_device: validate each capacity dimension has num_orders demand and fleet_size capacity values before building the device view, preventing an out-of-bounds read from an undersized (e.g. raw-proto) request. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/grpc_routing_problem_mapper.cpp | 22 +++++++++++++++++++- cpp/src/routing/cpu_routing_problem.cu | 19 ++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/grpc_routing_problem_mapper.cpp index c7cb618959..cf7557e0cf 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/grpc_routing_problem_mapper.cpp @@ -7,6 +7,8 @@ #include "grpc_routing_problem_mapper.hpp" +#include + #include #include @@ -49,6 +51,24 @@ void copy_bool_to_u8(const google::protobuf::RepeatedField& src, std::vect } } +// Mirror grpc_server_types.hpp's format_cuopt_error without pulling that heavy +// server header into this (client-linked) mapper: parse the structured +// {"error_type","msg"} payload logic_error embeds down to a clean "type: msg" +// so raw internal error detail is not sent to remote clients. +std::string sanitize_error_message(const cuopt::logic_error& e) +{ + std::string s = e.what(); + std::string msg; + auto pos = s.find("\"msg\": \""); + if (pos != std::string::npos) { + pos += 8; + auto end = s.rfind('"'); + if (end > pos) { msg = s.substr(pos, end - pos); } + } + if (msg.empty()) { msg = s; } + return cuopt::error_to_string(e.get_error_type()) + ": " + msg; +} + cuopt::remote::RoutingSolutionStatus to_proto_status(cuopt::routing::solution_status_t s) { using cuopt::routing::solution_status_t; @@ -324,7 +344,7 @@ void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assi pb->set_status_message(assignment.get_status_string()); if (assignment.get_status() == cuopt::routing::solution_status_t::ERROR) { try { - pb->set_error_message(assignment.get_error_status().what()); + pb->set_error_message(sanitize_error_message(assignment.get_error_status())); } catch (...) { pb->set_error_message("routing solve error"); } diff --git a/cpp/src/routing/cpu_routing_problem.cu b/cpp/src/routing/cpu_routing_problem.cu index 5ce2b41bb2..fb61c7f8cf 100644 --- a/cpp/src/routing/cpu_routing_problem.cu +++ b/cpp/src/routing/cpu_routing_problem.cu @@ -83,7 +83,12 @@ std::unique_ptr> copy_u8_as_bool(std::vector { if (host.empty()) { return nullptr; } std::vector as_bool(host.begin(), host.end()); - return std::make_unique>(cuopt::device_copy(as_bool, stream)); + auto d = std::make_unique>(cuopt::device_copy(as_bool, stream)); + // as_bool is a local temporary and the H2D copy above is async; drain the + // stream before it goes out of scope so the copy does not read freed host + // memory. + stream.synchronize(); + return d; } } // namespace @@ -201,6 +206,15 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const } for (auto const& dim : capacity_dimensions) { + // Validate client-provided sizes before they reach the device view: + // populate_demand_container reads exactly `orders` demand and `fleet_size` + // capacity values, so an undersized request would be an out-of-bounds read. + if (static_cast(dim.demand.size()) != orders || + static_cast(dim.capacity.size()) != fleet_size) { + throw std::invalid_argument( + "cpu_routing_problem_t::to_device: capacity dimension '" + dim.name + + "' must have num_orders demand values and fleet_size capacity values"); + } auto d_demand = copy_vector(dim.demand, stream); auto d_capacity = copy_vector(dim.capacity, stream); if (!d_demand || !d_capacity) { @@ -286,6 +300,9 @@ cpu_routing_problem_t::to_device(raft::handle_t* handle) const types.push_back(static_cast(t)); } data->init_types = copy_vector(types, stream); + // types is a local temporary feeding an async H2D copy; drain before it + // goes out of scope. + stream.synchronize(); int32_t n_nodes = static_cast(initial_solutions.routes.size()); int32_t n_sols = static_cast(initial_solutions.sol_offsets.size()); From ff54bba02a722a448e2930b4b9a41125eb1ef8b0 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 22 Jul 2026 15:51:34 -0500 Subject: [PATCH 08/17] Drive VRP proto/enum from field_registry (fix codegen-verify) The VRP server commit hand-edited two generated files, which broke the "verify gRPC codegen" check. Make both codegen-driven: - chunked_result_header: support scalars declared directly on the section (emit into the proto verbatim, no LP/MIP conversion). Declares is_vrp/routing_solution. - enum codegen: support per-value divergence between the C++ and proto enums -- `proto_only` (proto value with no C++ enumerator; from_proto throws) and `cpp_aliases` (C++-only enumerator serialized as another proto value). Declares problem_category as VRP (proto-only) + IP->MIP, so problem_category_t (LP/MIP/IP) and ProblemCategory (LP/MIP/VRP) stay decoupled without hand-editing the output. Regenerated files now match the registry; verify_grpc_codegen.sh passes and the server/converter compile. Other enums regenerate byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 19 ++++- cpp/src/grpc/codegen/generate_conversions.py | 74 ++++++++++++++----- .../codegen/generated/cuopt_remote_data.proto | 3 +- .../generated_enum_converters_problem.inc | 3 +- 4 files changed, 74 insertions(+), 25 deletions(-) diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 9567992275..143ab9a96f 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -165,10 +165,17 @@ enums: problem_category: domain: problem + # The C++ problem_category_t (LP, MIP, IP) and the proto ProblemCategory + # (LP, MIP, VRP) intentionally diverge: VRP is a transport-only routing + # category with no math-opt enumerator, and IP is a C++-only value that + # serializes as MIP on the wire. values: - LP - MIP - - VRP + - VRP: + proto_only: true # proto value with no problem_category_t; from_proto throws + cpp_aliases: + IP: MIP # C++-only enumerator; to_proto serializes it as MIP # ResultFieldId is auto-derived from solution/warm-start array_id fields. # Aliases here preserve backward compatibility with upstream's abbreviated @@ -403,6 +410,16 @@ chunked_result_header: type: ResultArrayDescriptor field_num: 50 repeated: true + # VRP result routing carried in the header. Set directly by the worker + # (set_is_vrp / set_routing_solution), so these only emit into the proto -- + # no LP/MIP conversion code is generated for them. + scalars: + - is_vrp: + field_num: 4000 + type: bool + - routing_solution: + field_num: 4001 + type: bytes # ───────────────────────────────────────────────────────────────────────────── # PDLP Solver Settings diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index 1cd4458230..a7382916ce 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -224,33 +224,38 @@ def parse_settings_fields(entries, prefix=""): def parse_enum_entry(entry, index=0): """Parse a single enum value entry. - Supports three forms: - - bare string 'CppName' → (name, index) - - {CppName: null} → (name, index) - - {CppName: number} → (name, number) - - The caller is responsible for tracking the running counter; explicit values - reset it (C-style enum semantics). + Supports: + - bare string 'CppName' → (name, index, {}) + - {CppName: null} → (name, index, {}) + - {CppName: number} → (name, number, {}) + - {CppName: {attr: val, ...}} → (name, attrs.get('num', index), attrs) + + The attribute form carries per-value semantics (e.g. ``proto_only: true`` + for a proto enum value that has no C++ enumerator). The caller tracks the + running counter; explicit numbers reset it (C-style enum semantics). """ if isinstance(entry, str): - return entry, index + return entry, index, {} assert isinstance(entry, dict) and len(entry) == 1 name = next(iter(entry)) - num = entry[name] - return name, num if num is not None else index + val = entry[name] + if isinstance(val, dict): + return name, val.get("num", index), val + return name, val if val is not None else index, {} def parse_enum_values(values): """Parse a full enum values list with C-style auto-numbering. Bare names get the next sequential number; explicit {Name: N} overrides - reset the counter so the next bare name gets N+1. + reset the counter so the next bare name gets N+1. Returns + ``(name, num, attrs)`` triples. """ counter = 0 result = [] for entry in values: - name, num = parse_enum_entry(entry, index=counter) - result.append((name, num)) + name, num, attrs = parse_enum_entry(entry, index=counter) + result.append((name, num, attrs)) counter = num + 1 return result @@ -369,7 +374,7 @@ def _enum_default(key, edef): if "default" in edef: return edef["default"] first = edef["values"][0] - name, _ = parse_enum_entry(first) + name, _, _ = parse_enum_entry(first) return name @@ -1024,7 +1029,7 @@ def generate_enum_proto_from_registry(registry): proto_type_name = _enum_proto_type(key, edef) prefix = edef.get("proto_prefix", "") lines = [f"enum {proto_type_name} {{"] - for cpp_name, num in parse_enum_values(edef["values"]): + for cpp_name, num, _attrs in parse_enum_values(edef["values"]): pname = _proto_enum_value_name(cpp_name, prefix) lines.append(f" {pname} = {num};") lines.append("}") @@ -1060,11 +1065,21 @@ def generate_enum_converters_inc(registry, domain=None): "{", " switch (v) {", ] - for cpp_name, _ in parse_enum_values(edef["values"]): + # to_proto: one case per C++ enumerator. proto_only values have no C++ + # enumerator (skip). cpp_aliases are C++-only enumerators that serialize + # to an existing proto value (e.g. IP -> MIP). + for cpp_name, _num, attrs in parse_enum_values(edef["values"]): + if attrs.get("proto_only"): + continue pname = _proto_enum_value_name(cpp_name, prefix) lines.append( f" case {cpp_type}::{cpp_name}: return cuopt::remote::{pname};" ) + for cpp_alias, proto_target in edef.get("cpp_aliases", {}).items(): + ptarget = _proto_enum_value_name(proto_target, prefix) + lines.append( + f" case {cpp_type}::{cpp_alias}: return cuopt::remote::{ptarget};" + ) lines.extend( [ " }", @@ -1079,11 +1094,19 @@ def generate_enum_converters_inc(registry, domain=None): "{", " switch (v) {", ] - for cpp_name, _ in parse_enum_values(edef["values"]): + # from_proto: one case per proto value. proto_only values have no C++ + # enumerator, so their wire value is rejected at runtime. + for cpp_name, _num, attrs in parse_enum_values(edef["values"]): pname = _proto_enum_value_name(cpp_name, prefix) - lines.append( - f" case cuopt::remote::{pname}: return {cpp_type}::{cpp_name};" - ) + if attrs.get("proto_only"): + lines.append( + f" case cuopt::remote::{pname}:\n" + f' throw std::invalid_argument("{pname} has no {cpp_type} equivalent");' + ) + else: + lines.append( + f" case cuopt::remote::{pname}: return {cpp_type}::{cpp_name};" + ) lines.extend( [ " }", @@ -1439,6 +1462,17 @@ def generate_chunked_result_header_proto(registry): ) ) lines.extend(_iter_embeds(registry.get("chunked_result_header", {}))) + # Scalars declared directly on chunked_result_header (e.g. VRP is_vrp / + # routing_solution). These are set on the header by the worker directly, so + # they only emit into the proto -- no LP/MIP conversion code is generated + # for them. Emit the field's declared type verbatim so bytes/bool work. + for entry in registry.get("chunked_result_header", {}).get("scalars", []): + f = parse_field(entry) + num = f.get("field_num") + if num is not None: + lines.append( + (num, f" {f.get('type', 'double')} {f['name']} = {num};") + ) lines.sort(key=lambda x: x[0]) return "\n".join(item[1] for item in lines) diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index 0b343908e4..aa00183582 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -344,7 +344,6 @@ message ChunkedResultHeader { double ws_last_restart_kkt_score = 3005; double ws_sum_solution_weight = 3006; int32 ws_iterations_since_last_restart = 3007; - // VRP (manual extension; not yet driven by field_registry.yaml) bool is_vrp = 4000; - bytes routing_solution = 4001; // serialized RoutingSolution proto + bytes routing_solution = 4001; } diff --git a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc index 8675483756..7972a7e4ce 100644 --- a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc +++ b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc @@ -38,8 +38,7 @@ problem_category_t from_proto_problem_category(cuopt::remote::ProblemCategory v) case cuopt::remote::LP: return problem_category_t::LP; case cuopt::remote::MIP: return problem_category_t::MIP; case cuopt::remote::VRP: - throw std::invalid_argument( - "VRP is not a mathematical_optimization::problem_category_t"); + throw std::invalid_argument("VRP has no problem_category_t equivalent"); } throw std::invalid_argument("Unknown cuopt::remote::ProblemCategory: " + std::to_string(static_cast(v))); } From 08a8bbc72d758d245ef53b1270180376f4d0c7f7 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Wed, 22 Jul 2026 16:33:21 -0500 Subject: [PATCH 09/17] Rename routing grpc tests to unique basenames tests/routing/test_grpc_client.py collided with the existing tests/linear_programming/test_grpc_client.py under pytest's default prepend import mode (no __init__.py in the test dirs), causing an import-file-mismatch collection error when the whole tests tree is collected (wheel tests). Give the routing grpc tests routing-prefixed basenames so they are unique. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- .../routing/{test_grpc_client.py => test_routing_grpc_client.py} | 0 ...t_grpc_serialization.py => test_routing_grpc_serialization.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename python/cuopt/cuopt/tests/routing/{test_grpc_client.py => test_routing_grpc_client.py} (100%) rename python/cuopt/cuopt/tests/routing/{test_grpc_serialization.py => test_routing_grpc_serialization.py} (100%) diff --git a/python/cuopt/cuopt/tests/routing/test_grpc_client.py b/python/cuopt/cuopt/tests/routing/test_routing_grpc_client.py similarity index 100% rename from python/cuopt/cuopt/tests/routing/test_grpc_client.py rename to python/cuopt/cuopt/tests/routing/test_routing_grpc_client.py diff --git a/python/cuopt/cuopt/tests/routing/test_grpc_serialization.py b/python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py similarity index 100% rename from python/cuopt/cuopt/tests/routing/test_grpc_serialization.py rename to python/cuopt/cuopt/tests/routing/test_routing_grpc_serialization.py From 5db33e2efe04c3a4d9fa99e8552ec3f5bbb4547c Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 23 Jul 2026 15:39:44 -0500 Subject: [PATCH 10/17] grpc: keep problem-representation logic out of the codegen for VRP Address review: the generated problem_category converter (unused; the job category is assigned directly from the request oneof in the service) was carrying problem-representation decisions (IP->MIP alias, VRP->throw). Mark problem_category as proto_enum_only so codegen emits only the wire enum and no C++ <-> proto converter. Move VRP to 1000 to leave the low numbers for future math-opt categories (QP, SOCP, ...). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 16 ++++----- cpp/src/grpc/codegen/generate_conversions.py | 34 ++++++------------- .../codegen/generated/cuopt_remote_data.proto | 2 +- .../generated_enum_converters_problem.inc | 21 ------------ 4 files changed, 20 insertions(+), 53 deletions(-) diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index 143ab9a96f..92eb2f2ec0 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -165,17 +165,17 @@ enums: problem_category: domain: problem - # The C++ problem_category_t (LP, MIP, IP) and the proto ProblemCategory - # (LP, MIP, VRP) intentionally diverge: VRP is a transport-only routing - # category with no math-opt enumerator, and IP is a C++-only value that - # serializes as MIP on the wire. + # Transport-only wire enum. Deliberately generates NO C++ <-> proto converter + # (proto_enum_only): the job's category is assigned directly from the request + # oneof in the service (has_lp_request / has_mip_request / has_vrp_request), so + # no enum conversion — and no problem-representation decision — lives in the + # grpc codegen. VRP sits at 1000 to leave the low numbers for future math-opt + # categories (QP, SOCP, ...). + proto_enum_only: true values: - LP - MIP - - VRP: - proto_only: true # proto value with no problem_category_t; from_proto throws - cpp_aliases: - IP: MIP # C++-only enumerator; to_proto serializes it as MIP + - VRP: 1000 # ResultFieldId is auto-derived from solution/warm-start array_id fields. # Aliases here preserve backward compatibility with upstream's abbreviated diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index a7382916ce..d4359b6e19 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -1049,6 +1049,10 @@ def generate_enum_converters_inc(registry, domain=None): for key, edef in enums.items(): if "values" not in edef: continue + # proto_enum_only enums are wire-only: emit the proto enum (elsewhere) but + # no C++ <-> proto converter, so no problem-representation mapping lives here. + if edef.get("proto_enum_only"): + continue if domain is not None and edef.get("domain") != domain: continue cpp_type = _enum_cpp_type(key, edef) @@ -1065,21 +1069,12 @@ def generate_enum_converters_inc(registry, domain=None): "{", " switch (v) {", ] - # to_proto: one case per C++ enumerator. proto_only values have no C++ - # enumerator (skip). cpp_aliases are C++-only enumerators that serialize - # to an existing proto value (e.g. IP -> MIP). - for cpp_name, _num, attrs in parse_enum_values(edef["values"]): - if attrs.get("proto_only"): - continue + # to_proto: one case per C++ enumerator. + for cpp_name, _num, _attrs in parse_enum_values(edef["values"]): pname = _proto_enum_value_name(cpp_name, prefix) lines.append( f" case {cpp_type}::{cpp_name}: return cuopt::remote::{pname};" ) - for cpp_alias, proto_target in edef.get("cpp_aliases", {}).items(): - ptarget = _proto_enum_value_name(proto_target, prefix) - lines.append( - f" case {cpp_type}::{cpp_alias}: return cuopt::remote::{ptarget};" - ) lines.extend( [ " }", @@ -1094,19 +1089,12 @@ def generate_enum_converters_inc(registry, domain=None): "{", " switch (v) {", ] - # from_proto: one case per proto value. proto_only values have no C++ - # enumerator, so their wire value is rejected at runtime. - for cpp_name, _num, attrs in parse_enum_values(edef["values"]): + # from_proto: one case per proto value. + for cpp_name, _num, _attrs in parse_enum_values(edef["values"]): pname = _proto_enum_value_name(cpp_name, prefix) - if attrs.get("proto_only"): - lines.append( - f" case cuopt::remote::{pname}:\n" - f' throw std::invalid_argument("{pname} has no {cpp_type} equivalent");' - ) - else: - lines.append( - f" case cuopt::remote::{pname}: return {cpp_type}::{cpp_name};" - ) + lines.append( + f" case cuopt::remote::{pname}: return {cpp_type}::{cpp_name};" + ) lines.extend( [ " }", diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index aa00183582..94206ca05e 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -51,7 +51,7 @@ enum VariableType { enum ProblemCategory { LP = 0; MIP = 1; - VRP = 2; + VRP = 1000; } enum ResultFieldId { diff --git a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc index 7972a7e4ce..e14510cdb8 100644 --- a/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc +++ b/cpp/src/grpc/codegen/generated/generated_enum_converters_problem.inc @@ -21,24 +21,3 @@ var_t from_proto_variable_type(cuopt::remote::VariableType v) } throw std::invalid_argument("Unknown cuopt::remote::VariableType: " + std::to_string(static_cast(v))); } - -cuopt::remote::ProblemCategory to_proto_problem_category(problem_category_t v) -{ - switch (v) { - case problem_category_t::LP: return cuopt::remote::LP; - case problem_category_t::MIP: return cuopt::remote::MIP; - case problem_category_t::IP: return cuopt::remote::MIP; - } - throw std::invalid_argument("Unknown problem_category_t: " + std::to_string(static_cast(v))); -} - -problem_category_t from_proto_problem_category(cuopt::remote::ProblemCategory v) -{ - switch (v) { - case cuopt::remote::LP: return problem_category_t::LP; - case cuopt::remote::MIP: return problem_category_t::MIP; - case cuopt::remote::VRP: - throw std::invalid_argument("VRP has no problem_category_t equivalent"); - } - throw std::invalid_argument("Unknown cuopt::remote::ProblemCategory: " + std::to_string(static_cast(v))); -} From ae8d5d8a9ef7cc15fdddd060303bcd45ad844a32 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Mon, 27 Jul 2026 10:10:40 -0500 Subject: [PATCH 11/17] grpc: state problem_category enum values explicitly (LP=0, MIP=1) Address review: make the wire numbers explicit for LP and MIP rather than relying on auto-numbering, mirroring the explicit VRP=1000. No wire change (the generated proto is byte-identical). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/codegen/field_registry.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index f8c303c979..ca144cd7ef 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -173,8 +173,8 @@ enums: # categories (QP, SOCP, ...). proto_enum_only: true values: - - LP - - MIP + - LP: 0 + - MIP: 1 - VRP: 1000 # ResultFieldId is auto-derived from solution/warm-start array_id fields. From 1f2efb4fe09a6785c8053628294b0adcb6a9e077 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 28 Jul 2026 10:24:22 -0500 Subject: [PATCH 12/17] grpc/vrp: fix result size accounting, time_limit=0, and client error propagation Address review must-fixes for the VRP gRPC path: - publish_result: include the embedded routing_solution bytes in result_total_bytes. VRP puts its payload in the ChunkedResultHeader, not in sr.arrays, so data_size stayed ~0 and the GetResult oversized-result guard (RESOURCE_EXHAUSTED) and reported size were wrong. - time_limit: presence-track it (optional in the proto, has_time_limit() on the server, sentinel-aware client mapping). An explicit time_limit=0 now survives to the solver instead of being dropped by the old `if (time_limit > 0)` guard; an omitted value still lets the solver derive its default (num_orders / 5). - RoutingClient.wait()/delete() now raise RoutingSolveError on failure and solve() surfaces the underlying reason, matching the LP/MILP client instead of swallowing errors. Exposes the grpc_status_result_t success/error_message fields in the .pxd to do so. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/src/grpc/cuopt_routing.proto | 4 ++- cpp/src/grpc/grpc_routing_problem_mapper.cpp | 11 ++++++-- cpp/src/grpc/server/grpc_worker.cpp | 4 +++ .../cuopt/cuopt/grpc/routing/grpc_client.pxd | 4 +++ .../cuopt/cuopt/grpc/routing/grpc_client.pyx | 26 ++++++++++++++----- 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/cpp/src/grpc/cuopt_routing.proto b/cpp/src/grpc/cuopt_routing.proto index de1d399f18..8409c7cd12 100644 --- a/cpp/src/grpc/cuopt_routing.proto +++ b/cpp/src/grpc/cuopt_routing.proto @@ -124,7 +124,9 @@ message RoutingProblem { // --- Settings --- message RoutingSolverSettings { - float time_limit = 1; + // Presence-tracked: an omitted time_limit lets the solver derive its default + // (num_orders / 5), while an explicit value — including 0 — is honored. + optional float time_limit = 1; bool verbose = 2; bool error_logging = 3; string dump_best_results_path = 10; diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/grpc_routing_problem_mapper.cpp index cf7557e0cf..ee6c066e4e 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/grpc_routing_problem_mapper.cpp @@ -11,6 +11,7 @@ #include #include +#include namespace cuopt { namespace mathematical_optimization { @@ -299,7 +300,8 @@ void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, cuopt::routing::solver_settings_t& settings) { - if (pb.time_limit() > 0) { settings.set_time_limit(pb.time_limit()); } + // Honor an explicit time_limit (including 0); absence means "use solver default". + if (pb.has_time_limit()) { settings.set_time_limit(pb.time_limit()); } settings.set_verbose_mode(pb.verbose()); settings.set_error_logging_mode(pb.error_logging()); if (!pb.dump_best_results_path().empty()) { @@ -311,7 +313,12 @@ void map_routing_settings_to_proto(const cuopt::routing::solver_settings_tClear(); - pb->set_time_limit(settings.get_time_limit()); + // Only emit time_limit when the caller set it; the unset sentinel (f_t max) + // stays absent so the server derives its default instead of receiving a huge + // value. An explicit 0 is a real value and is forwarded. + if (settings.get_time_limit() != std::numeric_limits::max()) { + pb->set_time_limit(settings.get_time_limit()); + } pb->set_verbose(settings.get_verbose_mode()); pb->set_error_logging(settings.get_error_logging_mode()); auto [interval, dump, path] = settings.get_dump_best_results(); diff --git a/cpp/src/grpc/server/grpc_worker.cpp b/cpp/src/grpc/server/grpc_worker.cpp index 82e1a3f699..232faf0180 100644 --- a/cpp/src/grpc/server/grpc_worker.cpp +++ b/cpp/src/grpc/server/grpc_worker.cpp @@ -574,6 +574,10 @@ static void publish_result(const SolveResult& sr, const std::string& job_id, int for (const auto& [fid, data] : sr.arrays) { result_total_bytes += data.size(); } + // VRP embeds its solution in the header (sr.header.routing_solution), not in + // sr.arrays. Count it too, otherwise data_size stays ~0 and the GetResult + // oversized-result guard (RESOURCE_EXHAUSTED) and reported size are wrong. + result_total_bytes += static_cast(sr.header.routing_solution().size()); } // Same CAS protocol as store_simple_result (see comment there). diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd index 748643c3ca..3099e2f3bb 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pxd @@ -115,7 +115,11 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": string job_id cdef cppclass grpc_status_result_t: + bint success + string error_message grpc_job_status_t status + string message + long long result_size_bytes cdef cppclass grpc_vrp_result_outcome_t: bool not_ready diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx index 6b0fa031cb..7549099c2e 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx @@ -361,10 +361,16 @@ cdef class RoutingClient: return sub.job_id.decode("utf-8") def wait(self, str job_id, int timeout=0): - """Block until the job finishes; return the terminal status int.""" + """Block until the job finishes; return the terminal status int. + + Raises ``RoutingSolveError`` if the wait itself fails (e.g. transport + error or unknown job), mirroring the LP/MILP client. + """ cdef grpc_status_result_t st = self._client.get().wait( job_id.encode("utf-8"), timeout ) + if not st.success: + raise RoutingSolveError(st.error_message.decode("utf-8")) return st.status def result(self, str job_id): @@ -382,18 +388,24 @@ cdef class RoutingClient: return _solution_to_py(out.solution) def delete(self, str job_id): + """Delete a job's server-side result; raise ``RoutingSolveError`` on failure.""" cdef string err - self._client.get().delete_job(job_id.encode("utf-8"), err) + if not self._client.get().delete_job(job_id.encode("utf-8"), err): + raise RoutingSolveError(err.decode("utf-8")) def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): """Submit, wait, and return the solution (the common path).""" job_id = self.submit(data_model, settings) - status = self.wait(job_id, timeout) - if status != COMPLETED: - raise RoutingSolveError( - f"job {job_id} did not complete (status {status})" - ) try: + status = self.wait(job_id, timeout) + if status != COMPLETED: + # A non-completed terminal status means the solve failed; + # result() surfaces the server's error_message. If it somehow + # doesn't raise, fall back to a status-only message. + self.result(job_id) + raise RoutingSolveError( + f"job {job_id} did not complete (status {status})" + ) return self.result(job_id) finally: if delete: From e4ccc7514a759905c6587f421af6a1f8162a9630 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Tue, 28 Jul 2026 14:00:34 -0500 Subject: [PATCH 13/17] grpc/vrp: carry RoutingSolution as a structured message, not a bytes blob Replace the `bytes routing_solution` workaround with a structured `RoutingSolution` field in both ResultResponse and ChunkedResultHeader. The blob existed only to dodge a proto import cycle: RoutingSolution lived in cuopt_routing.proto, which imports cuopt_remote.proto, so the result messages couldn't reference it structurally. Fix by moving RoutingSolution / RoutingSolutionStatus into a leaf proto (cuopt_routing_solution.proto, no imports) that both result messages import. The codegen now emits registry-driven `proto_imports` into the generated cuopt_remote_data.proto, so no hand-written type lands in the generated file. Wire-compatible: a bytes field and an embedded message at the same field number encode identically (the bytes were already RoutingSolution.SerializeToString()), so this does not break existing gRPC clients. Worker/service/client now read and write the structured field directly (no manual SerializeToString/ParseFromString). Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Ramakrishna Prabhu --- cpp/CMakeLists.txt | 27 +++++++++++-- cpp/src/grpc/client/grpc_client.cpp | 8 +--- cpp/src/grpc/codegen/field_registry.yaml | 9 ++++- cpp/src/grpc/codegen/generate_conversions.py | 8 ++++ .../codegen/generated/cuopt_remote_data.proto | 4 +- cpp/src/grpc/cuopt_remote.proto | 8 ++-- cpp/src/grpc/cuopt_routing.proto | 29 ++------------ cpp/src/grpc/cuopt_routing_solution.proto | 38 +++++++++++++++++++ cpp/src/grpc/grpc_routing_problem_mapper.hpp | 1 + cpp/src/grpc/server/grpc_service_impl.cpp | 2 +- cpp/src/grpc/server/grpc_worker.cpp | 16 +++----- .../routing/grpc/grpc_vrp_test_driver.cpp | 11 ++---- 12 files changed, 99 insertions(+), 62 deletions(-) create mode 100644 cpp/src/grpc/cuopt_routing_solution.proto diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 65e0de2d5e..28475cbd1a 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -389,6 +389,23 @@ if (NOT SKIP_GRPC_BUILD) set(PROTO_PATH_MANUAL "${CMAKE_CURRENT_SOURCE_DIR}/src/grpc") set(PROTO_PATH_GEN "${CMAKE_CURRENT_SOURCE_DIR}/src/grpc/codegen/generated") + # Routing solution proto (leaf; no imports). Embedded structurally by the + # generated data proto and by cuopt_remote.proto, so it is generated first. + set(ROUTING_SOLUTION_PROTO_FILE "${PROTO_PATH_MANUAL}/cuopt_routing_solution.proto") + set(ROUTING_SOLUTION_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing_solution.pb.cc") + set(ROUTING_SOLUTION_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing_solution.pb.h") + + add_custom_command( + OUTPUT "${ROUTING_SOLUTION_PROTO_SRCS}" "${ROUTING_SOLUTION_PROTO_HDRS}" + COMMAND ${_PROTOBUF_PROTOC} + ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} + --proto_path ${PROTO_PATH_MANUAL} + ${ROUTING_SOLUTION_PROTO_FILE} + DEPENDS ${ROUTING_SOLUTION_PROTO_FILE} + COMMENT "Generating C++ code from cuopt_routing_solution.proto" + VERBATIM + ) + # Generate C++ code from cuopt_remote_data.proto (auto-generated data definitions) set(DATA_PROTO_FILE "${PROTO_PATH_GEN}/cuopt_remote_data.proto") set(DATA_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_remote_data.pb.cc") @@ -399,8 +416,9 @@ if (NOT SKIP_GRPC_BUILD) COMMAND ${_PROTOBUF_PROTOC} ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} --proto_path ${PROTO_PATH_GEN} + --proto_path ${PROTO_PATH_MANUAL} ${DATA_PROTO_FILE} - DEPENDS ${DATA_PROTO_FILE} + DEPENDS ${DATA_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_remote_data.proto" VERBATIM ) @@ -417,7 +435,7 @@ if (NOT SKIP_GRPC_BUILD) --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} ${PROTO_FILE} - DEPENDS ${PROTO_FILE} ${DATA_PROTO_FILE} + DEPENDS ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_remote.proto" VERBATIM ) @@ -441,7 +459,7 @@ if (NOT SKIP_GRPC_BUILD) --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} ${ROUTING_PROTO_FILE} - DEPENDS ${ROUTING_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} + DEPENDS ${ROUTING_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_routing.proto" VERBATIM ) @@ -455,7 +473,7 @@ if (NOT SKIP_GRPC_BUILD) --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} ${GRPC_PROTO_FILE} - DEPENDS ${GRPC_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_PROTO_FILE} + DEPENDS ${GRPC_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating gRPC C++ code from cuopt_remote_service.proto" VERBATIM ) @@ -505,6 +523,7 @@ endif () if (NOT SKIP_GRPC_BUILD) # Add gRPC mapper files and generated protobuf sources set(GRPC_INFRA_FILES + ${ROUTING_SOLUTION_PROTO_SRCS} ${DATA_PROTO_SRCS} ${PROTO_SRCS} ${ROUTING_PROTO_SRCS} diff --git a/cpp/src/grpc/client/grpc_client.cpp b/cpp/src/grpc/client/grpc_client.cpp index 29ad1fae23..ef5d4af74f 100644 --- a/cpp/src/grpc/client/grpc_client.cpp +++ b/cpp/src/grpc/client/grpc_client.cpp @@ -811,12 +811,8 @@ remote_vrp_result_t grpc_client_t::get_vrp_result(const std::string& job_id) return result; } - cuopt::remote::RoutingSolution sol_pb; - if (!sol_pb.ParseFromString(dl.response->routing_solution())) { - result.error_message = "failed to parse RoutingSolution from server response"; - return result; - } - map_proto_to_routing_solution(sol_pb, result.solution); + // routing_solution is a structured message now (no manual parse). + map_proto_to_routing_solution(dl.response->routing_solution(), result.solution); result.success = true; return result; } diff --git a/cpp/src/grpc/codegen/field_registry.yaml b/cpp/src/grpc/codegen/field_registry.yaml index ca144cd7ef..f5941c2c5b 100644 --- a/cpp/src/grpc/codegen/field_registry.yaml +++ b/cpp/src/grpc/codegen/field_registry.yaml @@ -109,6 +109,13 @@ # Proto value names: proto_prefix + UPPER_SNAKE(CppName), or just CppName if # no prefix. # + +# Extra `import "...";` lines emitted at the top of the generated +# cuopt_remote_data.proto — needed when a field references a message defined in +# a hand-authored proto (e.g. ChunkedResultHeader.routing_solution : RoutingSolution). +proto_imports: + - cuopt_routing_solution.proto + enums: pdlp_termination_status: domain: solution @@ -419,7 +426,7 @@ chunked_result_header: type: bool - routing_solution: field_num: 4001 - type: bytes + type: RoutingSolution # structured; from cuopt_routing_solution.proto (see proto_imports) # ───────────────────────────────────────────────────────────────────────────── # PDLP Solver Settings diff --git a/cpp/src/grpc/codegen/generate_conversions.py b/cpp/src/grpc/codegen/generate_conversions.py index 86bdd3d502..562b267958 100644 --- a/cpp/src/grpc/codegen/generate_conversions.py +++ b/cpp/src/grpc/codegen/generate_conversions.py @@ -1552,6 +1552,14 @@ def generate_data_proto(registry): "", "package cuopt.remote;", "", + ] + # Extra imports for fields that reference hand-authored messages + # (e.g. ChunkedResultHeader.routing_solution : RoutingSolution). + for imp in registry.get("proto_imports", []): + parts.append(f'import "{imp}";') + if registry.get("proto_imports"): + parts.append("") + parts += [ enums, "", rfid, diff --git a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto index bfb72dbc22..efee5a8de4 100644 --- a/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto +++ b/cpp/src/grpc/codegen/generated/cuopt_remote_data.proto @@ -5,6 +5,8 @@ syntax = "proto3"; package cuopt.remote; +import "cuopt_routing_solution.proto"; + enum PDLPTerminationStatus { PDLP_NO_TERMINATION = 0; PDLP_NUMERICAL_ERROR = 1; @@ -345,5 +347,5 @@ message ChunkedResultHeader { double ws_sum_solution_weight = 3006; int32 ws_iterations_since_last_restart = 3007; bool is_vrp = 4000; - bytes routing_solution = 4001; + RoutingSolution routing_solution = 4001; } diff --git a/cpp/src/grpc/cuopt_remote.proto b/cpp/src/grpc/cuopt_remote.proto index ebd1852c9c..8c60257cd2 100644 --- a/cpp/src/grpc/cuopt_remote.proto +++ b/cpp/src/grpc/cuopt_remote.proto @@ -9,6 +9,8 @@ package cuopt.remote; // auto-generated from field_registry.yaml. Using "import public" so that any // file importing cuopt_remote.proto also sees the data types. import public "cuopt_remote_data.proto"; +// RoutingSolution (leaf proto, no back-imports) embedded structurally below. +import "cuopt_routing_solution.proto"; // Protocol version and metadata message RequestHeader { @@ -58,10 +60,6 @@ message StatusResponse { } // Response for get result -// Note: RoutingSolution lives in cuopt_routing.proto. To avoid a circular -// import (cuopt_routing.proto imports this file for RequestHeader), the VRP -// solution is carried as a serialized RoutingSolution blob here. Callers -// parse it with RoutingSolution::ParseFromString. message ResultResponse { ResponseStatus status = 1; string error_message = 2; @@ -69,7 +67,7 @@ message ResultResponse { oneof solution { LPSolution lp_solution = 10; MIPSolution mip_solution = 11; - bytes routing_solution = 12; // serialized cuopt.remote.RoutingSolution + RoutingSolution routing_solution = 12; } } diff --git a/cpp/src/grpc/cuopt_routing.proto b/cpp/src/grpc/cuopt_routing.proto index 8409c7cd12..4ff59ff117 100644 --- a/cpp/src/grpc/cuopt_routing.proto +++ b/cpp/src/grpc/cuopt_routing.proto @@ -142,29 +142,6 @@ message SolveVRPRequest { } // --- Response --- - -enum RoutingSolutionStatus { - ROUTING_SUCCESS = 0; - ROUTING_INFEASIBLE = 1; - ROUTING_TIMEOUT = 2; - ROUTING_EMPTY = 3; - ROUTING_ERROR = 4; -} - -message RoutingSolution { - repeated int32 route = 1; - repeated double arrival_stamp = 2; - repeated int32 truck_id = 3; - repeated int32 locations = 4; - repeated int32 node_types = 5; - repeated int32 unserviced_nodes = 6; - repeated int32 accepted = 7; - - int32 vehicle_count = 10; - double total_objective_value = 11; - map objective_values = 12; - - RoutingSolutionStatus status = 20; - string status_message = 21; - string error_message = 22; -} +// RoutingSolution / RoutingSolutionStatus live in cuopt_routing_solution.proto +// (a leaf proto) so ResultResponse and ChunkedResultHeader can embed them +// structurally without a circular import. diff --git a/cpp/src/grpc/cuopt_routing_solution.proto b/cpp/src/grpc/cuopt_routing_solution.proto new file mode 100644 index 0000000000..78e5a795b4 --- /dev/null +++ b/cpp/src/grpc/cuopt_routing_solution.proto @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package cuopt.remote; + +// VRP routing solution. Kept in its own leaf proto with NO imports so it can be +// embedded structurally by both ResultResponse (cuopt_remote.proto) and the +// generated ChunkedResultHeader (cuopt_remote_data.proto) without creating a +// circular import — cuopt_routing.proto imports cuopt_remote.proto, so the +// solution types cannot live there if the result messages are to reference them. + +enum RoutingSolutionStatus { + ROUTING_SUCCESS = 0; + ROUTING_INFEASIBLE = 1; + ROUTING_TIMEOUT = 2; + ROUTING_EMPTY = 3; + ROUTING_ERROR = 4; +} + +message RoutingSolution { + repeated int32 route = 1; + repeated double arrival_stamp = 2; + repeated int32 truck_id = 3; + repeated int32 locations = 4; + repeated int32 node_types = 5; + repeated int32 unserviced_nodes = 6; + repeated int32 accepted = 7; + + int32 vehicle_count = 10; + double total_objective_value = 11; + map objective_values = 12; + + RoutingSolutionStatus status = 20; + string status_message = 21; + string error_message = 22; +} diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/grpc_routing_problem_mapper.hpp index ceee3481e1..5fe26971d1 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.hpp +++ b/cpp/src/grpc/grpc_routing_problem_mapper.hpp @@ -12,6 +12,7 @@ #include #include +#include // RoutingSolution / RoutingSolutionStatus namespace cuopt { namespace mathematical_optimization { diff --git a/cpp/src/grpc/server/grpc_service_impl.cpp b/cpp/src/grpc/server/grpc_service_impl.cpp index e6faa7cb93..06315b0b36 100644 --- a/cpp/src/grpc/server/grpc_service_impl.cpp +++ b/cpp/src/grpc/server/grpc_service_impl.cpp @@ -363,7 +363,7 @@ class CuOptRemoteServiceImpl final : public cuopt::remote::CuOptRemoteService::S // Build the full protobuf solution from the raw arrays that were read // back from the worker pipe by the result retrieval thread. if (it->second.problem_category == cuopt::remote::VRP || it->second.result_header.is_vrp()) { - response->set_routing_solution(it->second.result_header.routing_solution()); + *response->mutable_routing_solution() = it->second.result_header.routing_solution(); } else if (it->second.problem_category == cuopt::remote::MIP) { cuopt::remote::MIPSolution mip_solution; cuopt::mathematical_optimization::build_mip_solution_proto( diff --git a/cpp/src/grpc/server/grpc_worker.cpp b/cpp/src/grpc/server/grpc_worker.cpp index 232faf0180..c18d182d87 100644 --- a/cpp/src/grpc/server/grpc_worker.cpp +++ b/cpp/src/grpc/server/grpc_worker.cpp @@ -542,19 +542,13 @@ static SolveResult run_vrp_solve(DeserializedJob& dj, raft::handle_t& handle) auto assignment = cuopt::routing::solve(view, dj.routing_settings); cuopt::routing::host_assignment_t host(assignment); - cuopt::remote::RoutingSolution routing_solution; - map_routing_solution_to_proto(assignment, host, &routing_solution); - sr.header.set_problem_category(cuopt::remote::VRP); sr.header.set_is_vrp(true); - std::string serialized; - if (!routing_solution.SerializeToString(&serialized)) { - sr.error_message = "Failed to serialize RoutingSolution"; - return sr; - } - sr.header.set_routing_solution(std::move(serialized)); + // Embed the RoutingSolution structurally (ChunkedResultHeader.routing_solution + // is a message field now, not a serialized blob). + map_routing_solution_to_proto(assignment, host, sr.header.mutable_routing_solution()); SERVER_LOG_INFO("[Worker] Result path: VRP solution -> embedded RoutingSolution (%zu bytes)", - sr.header.routing_solution().size()); + sr.header.routing_solution().ByteSizeLong()); sr.success = true; } catch (const cuopt::logic_error& e) { sr.error_message = format_cuopt_error(e); @@ -577,7 +571,7 @@ static void publish_result(const SolveResult& sr, const std::string& job_id, int // VRP embeds its solution in the header (sr.header.routing_solution), not in // sr.arrays. Count it too, otherwise data_size stays ~0 and the GetResult // oversized-result guard (RESOURCE_EXHAUSTED) and reported size are wrong. - result_total_bytes += static_cast(sr.header.routing_solution().size()); + result_total_bytes += static_cast(sr.header.routing_solution().ByteSizeLong()); } // Same CAS protocol as store_simple_result (see comment there). diff --git a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp index 3c2a91b433..482d3efbe8 100644 --- a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp +++ b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include @@ -280,16 +281,12 @@ int main(int argc, char** argv) return 1; } if (!result_resp.has_routing_solution()) { - std::cerr << "Result missing routing_solution blob\n"; + std::cerr << "Result missing routing_solution\n"; return 1; } - cuopt::remote::RoutingSolution sol; - if (!sol.ParseFromString(result_resp.routing_solution())) { - std::cerr << "Failed to parse RoutingSolution\n"; - return 1; - } - print_solution(sol); + // routing_solution is a structured message now (no manual parse). + print_solution(result_resp.routing_solution()); return 0; } catch (std::exception const& e) { std::cerr << "Error: " << e.what() << "\n"; From f74a057eea6f6df3db2fe6f6132b05fc7bb938e6 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 13 Aug 2026 09:33:59 -0500 Subject: [PATCH 14/17] grpc: isolate the routing arm so the component split stays mechanical Move every gRPC source that depends on the routing engine under cpp/src/grpc/routing/ and give it its own CMake source list, so the libcuopt component split (#1622) and the follow-up per-solver split (#1635) can assign it to a target without moving code again: - cuopt_routing{,_solution}.proto move to src/grpc/routing with their own protoc search root; import names and generated file names are unchanged. - The single routing mapper is split by message kind to mirror the LP layout: grpc_routing_{problem,settings,solution}_mapper.cpp, with the shared repeated-field helpers in grpc_routing_mapper_utils.hpp. The mappers move from namespace cuopt::mathematical_optimization to cuopt::routing. - The VRP arms of grpc_client_t and grpc_python_client_t move into their own translation units (grpc_client_vrp.cpp, cython_grpc_client_vrp.cpp); grpc_python_client_t::impl_t moves to an internal header shared by both arms. grpc_client.cpp and cython_grpc_client.cpp no longer reference routing. - CMake splits GRPC_INFRA_FILES into GRPC_PROTO_GENERATED_FILES (always built; cuopt_remote.proto embeds RoutingSolution), GRPC_MATHOPT_FILES and GRPC_ROUTING_FILES. This also fixes an LP-only build (--build-lp-only, which sets SKIP_ROUTING_BUILD but leaves gRPC on): the routing mappers and VRP client paths were compiled unconditionally and referenced routing symbols that were never built. They are now gated on the new CUOPT_ENABLE_GRPC_ROUTING, and the server's VRP path fails the job with a clear message when the build has no routing support. --- cpp/CMakeLists.txt | 56 +++++- cpp/src/grpc/client/cython_grpc_client.cpp | 61 +------ .../grpc/client/cython_grpc_client_impl.hpp | 38 ++++ cpp/src/grpc/client/grpc_client.cpp | 56 ------ .../grpc/{ => routing}/cuopt_routing.proto | 0 .../cuopt_routing_solution.proto | 0 .../grpc/routing/cython_grpc_client_vrp.cpp | 64 +++++++ cpp/src/grpc/routing/grpc_client_vrp.cpp | 78 +++++++++ .../routing/grpc_routing_mapper_utils.hpp | 59 +++++++ .../grpc_routing_problem_mapper.cpp | 164 +----------------- .../routing/grpc_routing_problem_mapper.hpp | 24 +++ .../routing/grpc_routing_settings_mapper.cpp | 47 +++++ .../routing/grpc_routing_settings_mapper.hpp | 24 +++ .../routing/grpc_routing_solution_mapper.cpp | 111 ++++++++++++ .../grpc_routing_solution_mapper.hpp} | 19 +- cpp/src/grpc/server/grpc_worker.cpp | 32 +++- .../routing/grpc/grpc_vrp_test_driver.cpp | 4 +- 17 files changed, 535 insertions(+), 302 deletions(-) create mode 100644 cpp/src/grpc/client/cython_grpc_client_impl.hpp rename cpp/src/grpc/{ => routing}/cuopt_routing.proto (100%) rename cpp/src/grpc/{ => routing}/cuopt_routing_solution.proto (100%) create mode 100644 cpp/src/grpc/routing/cython_grpc_client_vrp.cpp create mode 100644 cpp/src/grpc/routing/grpc_client_vrp.cpp create mode 100644 cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp rename cpp/src/grpc/{ => routing}/grpc_routing_problem_mapper.cpp (60%) create mode 100644 cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp create mode 100644 cpp/src/grpc/routing/grpc_routing_settings_mapper.cpp create mode 100644 cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp create mode 100644 cpp/src/grpc/routing/grpc_routing_solution_mapper.cpp rename cpp/src/grpc/{grpc_routing_problem_mapper.hpp => routing/grpc_routing_solution_mapper.hpp} (52%) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 39d5c5f664..521e693c0a 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -395,6 +395,18 @@ if (NOT SKIP_GRPC_BUILD) add_compile_definitions(CUOPT_ENABLE_GRPC) message(STATUS "gRPC enabled (target gRPC::grpc++ is available)") + # The routing arm of the gRPC layer needs the routing engine. The wire + # protocol (including the routing protos) is always generated so LP-only + # builds still speak the same protocol; only the mappers and the VRP + # client/worker paths are dropped. + if (NOT SKIP_ROUTING_BUILD) + set(CUOPT_ENABLE_GRPC_ROUTING ON) + add_compile_definitions(CUOPT_ENABLE_GRPC_ROUTING) + message(STATUS "gRPC routing (VRP) support enabled") + else () + message(STATUS "gRPC routing (VRP) support disabled (SKIP_ROUTING_BUILD)") + endif () + # Find protoc compiler (provided by config package or target) if (TARGET protobuf::protoc) get_target_property(_PROTOBUF_PROTOC protobuf::protoc IMPORTED_LOCATION_RELEASE) @@ -423,13 +435,17 @@ if (NOT SKIP_GRPC_BUILD) endif () endif () - # Proto search paths: manual protos in src/grpc, generated data proto in src/grpc/codegen/generated + # Proto search paths: LP/protocol protos in src/grpc, routing protos in + # src/grpc/routing, generated data proto in src/grpc/codegen/generated. + # Routing protos live under their own search root so the routing gRPC layer + # stays a self-contained directory; their import names are unchanged. set(PROTO_PATH_MANUAL "${CMAKE_CURRENT_SOURCE_DIR}/src/grpc") set(PROTO_PATH_GEN "${CMAKE_CURRENT_SOURCE_DIR}/src/grpc/codegen/generated") + set(PROTO_PATH_ROUTING "${CMAKE_CURRENT_SOURCE_DIR}/src/grpc/routing") # Routing solution proto (leaf; no imports). Embedded structurally by the # generated data proto and by cuopt_remote.proto, so it is generated first. - set(ROUTING_SOLUTION_PROTO_FILE "${PROTO_PATH_MANUAL}/cuopt_routing_solution.proto") + set(ROUTING_SOLUTION_PROTO_FILE "${PROTO_PATH_ROUTING}/cuopt_routing_solution.proto") set(ROUTING_SOLUTION_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing_solution.pb.cc") set(ROUTING_SOLUTION_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing_solution.pb.h") @@ -437,7 +453,7 @@ if (NOT SKIP_GRPC_BUILD) OUTPUT "${ROUTING_SOLUTION_PROTO_SRCS}" "${ROUTING_SOLUTION_PROTO_HDRS}" COMMAND ${_PROTOBUF_PROTOC} ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} - --proto_path ${PROTO_PATH_MANUAL} + --proto_path ${PROTO_PATH_ROUTING} ${ROUTING_SOLUTION_PROTO_FILE} DEPENDS ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_routing_solution.proto" @@ -455,6 +471,7 @@ if (NOT SKIP_GRPC_BUILD) ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} --proto_path ${PROTO_PATH_GEN} --proto_path ${PROTO_PATH_MANUAL} + --proto_path ${PROTO_PATH_ROUTING} ${DATA_PROTO_FILE} DEPENDS ${DATA_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_remote_data.proto" @@ -472,6 +489,7 @@ if (NOT SKIP_GRPC_BUILD) ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} + --proto_path ${PROTO_PATH_ROUTING} ${PROTO_FILE} DEPENDS ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating C++ code from cuopt_remote.proto" @@ -486,7 +504,7 @@ if (NOT SKIP_GRPC_BUILD) set(GRPC_SERVICE_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_remote_service.grpc.pb.h") # Routing proto (standalone VRP messages; imported by service proto) - set(ROUTING_PROTO_FILE "${PROTO_PATH_MANUAL}/cuopt_routing.proto") + set(ROUTING_PROTO_FILE "${PROTO_PATH_ROUTING}/cuopt_routing.proto") set(ROUTING_PROTO_SRCS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing.pb.cc") set(ROUTING_PROTO_HDRS "${CMAKE_CURRENT_BINARY_DIR}/cuopt_routing.pb.h") @@ -494,6 +512,7 @@ if (NOT SKIP_GRPC_BUILD) OUTPUT "${ROUTING_PROTO_SRCS}" "${ROUTING_PROTO_HDRS}" COMMAND ${_PROTOBUF_PROTOC} ARGS --cpp_out ${CMAKE_CURRENT_BINARY_DIR} + --proto_path ${PROTO_PATH_ROUTING} --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} ${ROUTING_PROTO_FILE} @@ -510,6 +529,7 @@ if (NOT SKIP_GRPC_BUILD) --plugin=protoc-gen-grpc=${_GRPC_CPP_PLUGIN_EXECUTABLE} --proto_path ${PROTO_PATH_MANUAL} --proto_path ${PROTO_PATH_GEN} + --proto_path ${PROTO_PATH_ROUTING} ${GRPC_PROTO_FILE} DEPENDS ${GRPC_PROTO_FILE} ${PROTO_FILE} ${DATA_PROTO_FILE} ${ROUTING_PROTO_FILE} ${ROUTING_SOLUTION_PROTO_FILE} COMMENT "Generating gRPC C++ code from cuopt_remote_service.proto" @@ -559,24 +579,46 @@ if (DEFINE_ASSERT) endif () if (NOT SKIP_GRPC_BUILD) - # Add gRPC mapper files and generated protobuf sources - set(GRPC_INFRA_FILES + # Generated protobuf sources. The whole wire protocol is generated in every + # gRPC build, routing messages included: cuopt_remote.proto embeds + # RoutingSolution, so these carry no dependency on the routing engine. + set(GRPC_PROTO_GENERATED_FILES ${ROUTING_SOLUTION_PROTO_SRCS} ${DATA_PROTO_SRCS} ${PROTO_SRCS} ${ROUTING_PROTO_SRCS} ${GRPC_PROTO_SRCS} ${GRPC_SERVICE_SRCS} + ) + + # LP/MIP arm: proto <-> mathematical_optimization, plus the shared client + # infrastructure (connection, chunking, polling, Cython shim). + set(GRPC_MATHOPT_FILES src/grpc/grpc_problem_mapper.cpp src/grpc/grpc_solution_mapper.cpp src/grpc/grpc_settings_mapper.cpp src/grpc/grpc_service_mapper.cpp - src/grpc/grpc_routing_problem_mapper.cpp src/grpc/client/grpc_client.cpp src/grpc/client/grpc_client_env.cpp src/grpc/client/cython_grpc_client.cpp src/grpc/client/solve_remote.cpp ) + + # Routing (VRP) arm: everything that depends on the routing engine. Kept as + # its own list so a routing-only gRPC client can be split out of the + # cuopt_grpc component without moving code around again. + set(GRPC_ROUTING_FILES + src/grpc/routing/grpc_routing_problem_mapper.cpp + src/grpc/routing/grpc_routing_settings_mapper.cpp + src/grpc/routing/grpc_routing_solution_mapper.cpp + src/grpc/routing/grpc_client_vrp.cpp + src/grpc/routing/cython_grpc_client_vrp.cpp + ) + + set(GRPC_INFRA_FILES ${GRPC_PROTO_GENERATED_FILES} ${GRPC_MATHOPT_FILES}) + if (CUOPT_ENABLE_GRPC_ROUTING) + list(APPEND GRPC_INFRA_FILES ${GRPC_ROUTING_FILES}) + endif () list(APPEND CUOPT_SRC_FILES ${GRPC_INFRA_FILES}) # Always keep NDEBUG defined for gRPC infrastructure files so that abseil diff --git a/cpp/src/grpc/client/cython_grpc_client.cpp b/cpp/src/grpc/client/cython_grpc_client.cpp index 8d1b00a7fa..74409fc93e 100644 --- a/cpp/src/grpc/client/cython_grpc_client.cpp +++ b/cpp/src/grpc/client/cython_grpc_client.cpp @@ -13,6 +13,7 @@ #include #include +#include "cython_grpc_client_impl.hpp" #include "grpc_client.hpp" #include @@ -49,23 +50,6 @@ grpc_status_result_t map_status_result( return out; } -bool is_in_flight(grpc_job_status_t status) -{ - return status == grpc_job_status_t::QUEUED || status == grpc_job_status_t::PROCESSING; -} - -} // namespace - -struct grpc_python_client_t::impl_t { - cuopt::mathematical_optimization::grpc_client_t client; - explicit impl_t(cuopt::mathematical_optimization::grpc_client_config_t config) - : client(std::move(config)) - { - } -}; - -namespace { - cuopt::mathematical_optimization::grpc_client_config_t make_config( const std::string& host, int port, const grpc_python_client_connect_options_t& options) { @@ -151,49 +135,6 @@ grpc_submit_result_t grpc_python_client_t::submit( return out; } -grpc_submit_result_t grpc_python_client_t::submit_vrp( - cuopt::routing::cpu_routing_problem_t* problem, - cuopt::routing::solver_settings_t* settings) -{ - grpc_submit_result_t out; - if (problem == nullptr || settings == nullptr) { - out.error_message = "problem and settings must not be null"; - return out; - } - auto sub = impl_->client.submit_vrp(*problem, *settings); - out.success = sub.success; - out.error_message = sub.error_message; - out.job_id = sub.job_id; - out.is_mip = false; - return out; -} - -grpc_vrp_result_outcome_t grpc_python_client_t::result_vrp(const std::string& job_id) -{ - grpc_vrp_result_outcome_t out; - - // Mirror result(): surface a structured "still running" signal instead of a - // generic GetResult failure when a caller polls result_vrp on an in-flight job. - auto st = status(job_id); - if (!st.success) { - out.error_message = st.error_message; - return out; - } - if (is_in_flight(st.status)) { - out.not_ready = true; - return out; - } - - auto remote = impl_->client.get_vrp_result(job_id); - if (!remote.success) { - out.error_message = remote.error_message; - return out; - } - out.success = true; - out.solution = std::move(remote.solution); - return out; -} - grpc_status_result_t grpc_python_client_t::status(const std::string& job_id) { return map_status_result(impl_->client.check_status(job_id)); diff --git a/cpp/src/grpc/client/cython_grpc_client_impl.hpp b/cpp/src/grpc/client/cython_grpc_client_impl.hpp new file mode 100644 index 0000000000..0cd1e80559 --- /dev/null +++ b/cpp/src/grpc/client/cython_grpc_client_impl.hpp @@ -0,0 +1,38 @@ +/* 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 + +// Internal detail shared by the translation units that implement +// grpc_python_client_t: the LP/MIP arm (cython_grpc_client.cpp) and the routing +// arm (grpc/routing/cython_grpc_client_vrp.cpp). Not installed. + +#include + +#include "grpc_client.hpp" + +#include + +namespace cuopt { +namespace cython { + +struct grpc_python_client_t::impl_t { + cuopt::mathematical_optimization::grpc_client_t client; + explicit impl_t(cuopt::mathematical_optimization::grpc_client_config_t config) + : client(std::move(config)) + { + } +}; + +/** A job the server has not finished yet; callers should keep polling. */ +inline bool is_in_flight(grpc_job_status_t status) +{ + return status == grpc_job_status_t::QUEUED || status == grpc_job_status_t::PROCESSING; +} + +} // namespace cython +} // namespace cuopt diff --git a/cpp/src/grpc/client/grpc_client.cpp b/cpp/src/grpc/client/grpc_client.cpp index 559b65da9a..3d1f6df54a 100644 --- a/cpp/src/grpc/client/grpc_client.cpp +++ b/cpp/src/grpc/client/grpc_client.cpp @@ -9,7 +9,6 @@ #include #include #include "grpc_problem_mapper.hpp" -#include "grpc_routing_problem_mapper.hpp" #include "grpc_service_mapper.hpp" #include "grpc_settings_mapper.hpp" #include "grpc_solution_mapper.hpp" @@ -814,61 +813,6 @@ remote_result_t grpc_client_t::get_result(const std::string& job_id) return result; } -// ============================================================================= -// VRP (routing) — unary-only submit / result / solve -// ============================================================================= - -submit_result_t grpc_client_t::submit_vrp( - const cuopt::routing::cpu_routing_problem_t& problem, - const cuopt::routing::solver_settings_t& settings) -{ - submit_result_t result; - - if (!is_connected()) { - result.error_message = "Not connected to server"; - return result; - } - - cuopt::remote::SubmitJobRequest submit_request; - auto* vrp = submit_request.mutable_vrp_request(); - vrp->mutable_header()->set_version(1); - vrp->mutable_header()->set_problem_category(cuopt::remote::VRP); - map_routing_problem_to_proto(problem, vrp->mutable_problem()); - map_routing_settings_to_proto(settings, vrp->mutable_settings()); - - if (!submit_unary(submit_request, result.job_id)) { - result.error_message = last_error_; - return result; - } - result.success = true; - return result; -} - -remote_vrp_result_t grpc_client_t::get_vrp_result(const std::string& job_id) -{ - remote_vrp_result_t result; - - if (!is_connected()) { - result.error_message = "Not connected to server"; - return result; - } - - downloaded_result_t dl; - if (!get_result_or_download(job_id, dl)) { - result.error_message = last_error_; - return result; - } - if (dl.was_chunked) { - result.error_message = "chunked VRP result download is not supported"; - return result; - } - - // routing_solution is a structured message now (no manual parse). - map_proto_to_routing_solution(dl.response->routing_solution(), result.solution); - result.success = true; - return result; -} - // ============================================================================= // Polling helper // ============================================================================= diff --git a/cpp/src/grpc/cuopt_routing.proto b/cpp/src/grpc/routing/cuopt_routing.proto similarity index 100% rename from cpp/src/grpc/cuopt_routing.proto rename to cpp/src/grpc/routing/cuopt_routing.proto diff --git a/cpp/src/grpc/cuopt_routing_solution.proto b/cpp/src/grpc/routing/cuopt_routing_solution.proto similarity index 100% rename from cpp/src/grpc/cuopt_routing_solution.proto rename to cpp/src/grpc/routing/cuopt_routing_solution.proto diff --git a/cpp/src/grpc/routing/cython_grpc_client_vrp.cpp b/cpp/src/grpc/routing/cython_grpc_client_vrp.cpp new file mode 100644 index 0000000000..326f56a71a --- /dev/null +++ b/cpp/src/grpc/routing/cython_grpc_client_vrp.cpp @@ -0,0 +1,64 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +// Routing arm of the Cython-facing client shim. Split out of +// cython_grpc_client.cpp so the routing gRPC path is a separate translation +// unit from the LP/MIP one (see grpc_client_vrp.cpp). + +#include "../client/cython_grpc_client_impl.hpp" + +#include +#include + +namespace cuopt { +namespace cython { + +grpc_submit_result_t grpc_python_client_t::submit_vrp( + cuopt::routing::cpu_routing_problem_t* problem, + cuopt::routing::solver_settings_t* settings) +{ + grpc_submit_result_t out; + if (problem == nullptr || settings == nullptr) { + out.error_message = "problem and settings must not be null"; + return out; + } + auto sub = impl_->client.submit_vrp(*problem, *settings); + out.success = sub.success; + out.error_message = sub.error_message; + out.job_id = sub.job_id; + out.is_mip = false; + return out; +} + +grpc_vrp_result_outcome_t grpc_python_client_t::result_vrp(const std::string& job_id) +{ + grpc_vrp_result_outcome_t out; + + // Mirror result(): surface a structured "still running" signal instead of a + // generic GetResult failure when a caller polls result_vrp on an in-flight job. + auto st = status(job_id); + if (!st.success) { + out.error_message = st.error_message; + return out; + } + if (is_in_flight(st.status)) { + out.not_ready = true; + return out; + } + + auto remote = impl_->client.get_vrp_result(job_id); + if (!remote.success) { + out.error_message = remote.error_message; + return out; + } + out.success = true; + out.solution = std::move(remote.solution); + return out; +} + +} // namespace cython +} // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_client_vrp.cpp b/cpp/src/grpc/routing/grpc_client_vrp.cpp new file mode 100644 index 0000000000..e35cb4ba9d --- /dev/null +++ b/cpp/src/grpc/routing/grpc_client_vrp.cpp @@ -0,0 +1,78 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +// VRP (routing) arm of grpc_client_t. Kept in its own translation unit so the +// routing gRPC path can be compiled (and later packaged) independently of the +// LP/MIP client: everything here depends on the routing engine, while +// grpc_client.cpp depends only on mathematical_optimization. + +#include "../client/grpc_client.hpp" + +#include "grpc_routing_problem_mapper.hpp" +#include "grpc_routing_settings_mapper.hpp" +#include "grpc_routing_solution_mapper.hpp" + +#include + +namespace cuopt::mathematical_optimization { + +using cuopt::routing::map_proto_to_routing_solution; +using cuopt::routing::map_routing_problem_to_proto; +using cuopt::routing::map_routing_settings_to_proto; + +submit_result_t grpc_client_t::submit_vrp( + const cuopt::routing::cpu_routing_problem_t& problem, + const cuopt::routing::solver_settings_t& settings) +{ + submit_result_t result; + + if (!is_connected()) { + result.error_message = "Not connected to server"; + return result; + } + + cuopt::remote::SubmitJobRequest submit_request; + auto* vrp = submit_request.mutable_vrp_request(); + vrp->mutable_header()->set_version(1); + vrp->mutable_header()->set_problem_category(cuopt::remote::VRP); + map_routing_problem_to_proto(problem, vrp->mutable_problem()); + map_routing_settings_to_proto(settings, vrp->mutable_settings()); + + if (!submit_unary(submit_request, result.job_id)) { + result.error_message = last_error_; + return result; + } + result.success = true; + return result; +} + +remote_vrp_result_t grpc_client_t::get_vrp_result(const std::string& job_id) +{ + remote_vrp_result_t result; + + if (!is_connected()) { + result.error_message = "Not connected to server"; + return result; + } + + downloaded_result_t dl; + if (!get_result_or_download(job_id, dl)) { + result.error_message = last_error_; + return result; + } + if (dl.was_chunked) { + result.error_message = "chunked VRP result download is not supported"; + return result; + } + + // routing_solution is a structured message now (no manual parse). + map_proto_to_routing_solution(dl.response->routing_solution(), result.solution); + result.success = true; + return result; +} + +} // namespace cuopt::mathematical_optimization diff --git a/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp b/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp new file mode 100644 index 0000000000..5cfbfd5685 --- /dev/null +++ b/cpp/src/grpc/routing/grpc_routing_mapper_utils.hpp @@ -0,0 +1,59 @@ +/* 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 + +namespace cuopt { +namespace routing { +namespace grpc_map_detail { + +template +inline void copy_repeated_to_vector(const google::protobuf::RepeatedField& src, + std::vector& dst) +{ + dst.assign(src.begin(), src.end()); +} + +template +inline void copy_vector_to_repeated(const std::vector& src, + google::protobuf::RepeatedField* dst) +{ + dst->Clear(); + dst->Reserve(static_cast(src.size())); + for (auto v : src) { + dst->Add(v); + } +} + +inline void copy_u32_to_u8(const google::protobuf::RepeatedField& src, + std::vector& dst) +{ + dst.clear(); + dst.reserve(static_cast(src.size())); + for (auto v : src) { + dst.push_back(static_cast(v)); + } +} + +inline void copy_bool_to_u8(const google::protobuf::RepeatedField& src, + std::vector& dst) +{ + dst.clear(); + dst.reserve(static_cast(src.size())); + for (bool v : src) { + dst.push_back(v ? 1 : 0); + } +} + +} // namespace grpc_map_detail +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.cpp b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp similarity index 60% rename from cpp/src/grpc/grpc_routing_problem_mapper.cpp rename to cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp index ee6c066e4e..a1125d5f53 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.cpp +++ b/cpp/src/grpc/routing/grpc_routing_problem_mapper.cpp @@ -7,83 +7,18 @@ #include "grpc_routing_problem_mapper.hpp" -#include +#include "grpc_routing_mapper_utils.hpp" -#include #include -#include +#include namespace cuopt { -namespace mathematical_optimization { +namespace routing { -namespace { - -template -void copy_repeated_to_vector(const google::protobuf::RepeatedField& src, std::vector& dst) -{ - dst.assign(src.begin(), src.end()); -} - -template -void copy_vector_to_repeated(const std::vector& src, google::protobuf::RepeatedField* dst) -{ - dst->Clear(); - dst->Reserve(static_cast(src.size())); - for (auto v : src) { - dst->Add(v); - } -} - -void copy_u32_to_u8(const google::protobuf::RepeatedField& src, std::vector& dst) -{ - dst.clear(); - dst.reserve(static_cast(src.size())); - for (auto v : src) { - dst.push_back(static_cast(v)); - } -} - -void copy_bool_to_u8(const google::protobuf::RepeatedField& src, std::vector& dst) -{ - dst.clear(); - dst.reserve(static_cast(src.size())); - for (bool v : src) { - dst.push_back(v ? 1 : 0); - } -} - -// Mirror grpc_server_types.hpp's format_cuopt_error without pulling that heavy -// server header into this (client-linked) mapper: parse the structured -// {"error_type","msg"} payload logic_error embeds down to a clean "type: msg" -// so raw internal error detail is not sent to remote clients. -std::string sanitize_error_message(const cuopt::logic_error& e) -{ - std::string s = e.what(); - std::string msg; - auto pos = s.find("\"msg\": \""); - if (pos != std::string::npos) { - pos += 8; - auto end = s.rfind('"'); - if (end > pos) { msg = s.substr(pos, end - pos); } - } - if (msg.empty()) { msg = s; } - return cuopt::error_to_string(e.get_error_type()) + ": " + msg; -} - -cuopt::remote::RoutingSolutionStatus to_proto_status(cuopt::routing::solution_status_t s) -{ - using cuopt::routing::solution_status_t; - switch (s) { - case solution_status_t::SUCCESS: return cuopt::remote::ROUTING_SUCCESS; - case solution_status_t::INFEASIBLE: return cuopt::remote::ROUTING_INFEASIBLE; - case solution_status_t::TIMEOUT: return cuopt::remote::ROUTING_TIMEOUT; - case solution_status_t::EMPTY: return cuopt::remote::ROUTING_EMPTY; - case solution_status_t::ERROR: return cuopt::remote::ROUTING_ERROR; - } - return cuopt::remote::ROUTING_ERROR; -} - -} // namespace +using grpc_map_detail::copy_bool_to_u8; +using grpc_map_detail::copy_repeated_to_vector; +using grpc_map_detail::copy_u32_to_u8; +using grpc_map_detail::copy_vector_to_repeated; void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, cuopt::routing::cpu_routing_problem_t& p) @@ -297,88 +232,5 @@ void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& p } } -void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, - cuopt::routing::solver_settings_t& settings) -{ - // Honor an explicit time_limit (including 0); absence means "use solver default". - if (pb.has_time_limit()) { settings.set_time_limit(pb.time_limit()); } - settings.set_verbose_mode(pb.verbose()); - settings.set_error_logging_mode(pb.error_logging()); - if (!pb.dump_best_results_path().empty()) { - settings.dump_best_results(pb.dump_best_results_path(), pb.dump_best_results_interval()); - } -} - -void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, - cuopt::remote::RoutingSolverSettings* pb) -{ - pb->Clear(); - // Only emit time_limit when the caller set it; the unset sentinel (f_t max) - // stays absent so the server derives its default instead of receiving a huge - // value. An explicit 0 is a real value and is forwarded. - if (settings.get_time_limit() != std::numeric_limits::max()) { - pb->set_time_limit(settings.get_time_limit()); - } - pb->set_verbose(settings.get_verbose_mode()); - pb->set_error_logging(settings.get_error_logging_mode()); - auto [interval, dump, path] = settings.get_dump_best_results(); - if (dump) { - pb->set_dump_best_results_path(path); - pb->set_dump_best_results_interval(interval); - } -} - -void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, - const cuopt::routing::host_assignment_t& host, - cuopt::remote::RoutingSolution* pb) -{ - pb->Clear(); - copy_vector_to_repeated(host.route, pb->mutable_route()); - copy_vector_to_repeated(host.stamp, pb->mutable_arrival_stamp()); - copy_vector_to_repeated(host.truck_id, pb->mutable_truck_id()); - copy_vector_to_repeated(host.locations, pb->mutable_locations()); - copy_vector_to_repeated(host.node_types, pb->mutable_node_types()); - copy_vector_to_repeated(host.unserviced_nodes, pb->mutable_unserviced_nodes()); - copy_vector_to_repeated(host.accepted, pb->mutable_accepted()); - - pb->set_vehicle_count(assignment.get_vehicle_count()); - pb->set_total_objective_value(assignment.get_total_objective()); - for (auto const& [obj, value] : assignment.get_objectives()) { - (*pb->mutable_objective_values())[static_cast(obj)] = value; - } - - pb->set_status(to_proto_status(assignment.get_status())); - pb->set_status_message(assignment.get_status_string()); - if (assignment.get_status() == cuopt::routing::solution_status_t::ERROR) { - try { - pb->set_error_message(sanitize_error_message(assignment.get_error_status())); - } catch (...) { - pb->set_error_message("routing solve error"); - } - } -} - -void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, - cuopt::routing::cpu_routing_solution_t& sol) -{ - copy_repeated_to_vector(pb.route(), sol.route); - copy_repeated_to_vector(pb.arrival_stamp(), sol.arrival_stamp); - copy_repeated_to_vector(pb.truck_id(), sol.truck_id); - copy_repeated_to_vector(pb.locations(), sol.locations); - copy_repeated_to_vector(pb.node_types(), sol.node_types); - copy_repeated_to_vector(pb.unserviced_nodes(), sol.unserviced_nodes); - copy_repeated_to_vector(pb.accepted(), sol.accepted); - - sol.vehicle_count = pb.vehicle_count(); - sol.total_objective_value = pb.total_objective_value(); - sol.objective_values.clear(); - for (auto const& [obj, value] : pb.objective_values()) { - sol.objective_values[static_cast(obj)] = value; - } - sol.status = static_cast(pb.status()); - sol.status_message = pb.status_message(); - sol.error_message = pb.error_message(); -} - -} // namespace mathematical_optimization +} // namespace routing } // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp new file mode 100644 index 0000000000..e3ff3630c2 --- /dev/null +++ b/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp @@ -0,0 +1,24 @@ +/* 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 + +namespace cuopt { +namespace routing { + +void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, + cuopt::routing::cpu_routing_problem_t& problem); + +void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& problem, + cuopt::remote::RoutingProblem* pb); + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_settings_mapper.cpp b/cpp/src/grpc/routing/grpc_routing_settings_mapper.cpp new file mode 100644 index 0000000000..ee166879da --- /dev/null +++ b/cpp/src/grpc/routing/grpc_routing_settings_mapper.cpp @@ -0,0 +1,47 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "grpc_routing_settings_mapper.hpp" + +#include + +namespace cuopt { +namespace routing { + +void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, + cuopt::routing::solver_settings_t& settings) +{ + // Honor an explicit time_limit (including 0); absence means "use solver default". + if (pb.has_time_limit()) { settings.set_time_limit(pb.time_limit()); } + settings.set_verbose_mode(pb.verbose()); + settings.set_error_logging_mode(pb.error_logging()); + if (!pb.dump_best_results_path().empty()) { + settings.dump_best_results(pb.dump_best_results_path(), pb.dump_best_results_interval()); + } +} + +void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, + cuopt::remote::RoutingSolverSettings* pb) +{ + pb->Clear(); + // Only emit time_limit when the caller set it; the unset sentinel (f_t max) + // stays absent so the server derives its default instead of receiving a huge + // value. An explicit 0 is a real value and is forwarded. + if (settings.get_time_limit() != std::numeric_limits::max()) { + pb->set_time_limit(settings.get_time_limit()); + } + pb->set_verbose(settings.get_verbose_mode()); + pb->set_error_logging(settings.get_error_logging_mode()); + auto [interval, dump, path] = settings.get_dump_best_results(); + if (dump) { + pb->set_dump_best_results_path(path); + pb->set_dump_best_results_interval(interval); + } +} + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp new file mode 100644 index 0000000000..551ccc7527 --- /dev/null +++ b/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp @@ -0,0 +1,24 @@ +/* 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 + +namespace cuopt { +namespace routing { + +void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, + cuopt::routing::solver_settings_t& settings); + +void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, + cuopt::remote::RoutingSolverSettings* pb); + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_solution_mapper.cpp b/cpp/src/grpc/routing/grpc_routing_solution_mapper.cpp new file mode 100644 index 0000000000..6abc47b791 --- /dev/null +++ b/cpp/src/grpc/routing/grpc_routing_solution_mapper.cpp @@ -0,0 +1,111 @@ +/* clang-format off */ +/* + * SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +/* clang-format on */ + +#include "grpc_routing_solution_mapper.hpp" + +#include "grpc_routing_mapper_utils.hpp" + +#include + +#include +#include + +namespace cuopt { +namespace routing { + +using grpc_map_detail::copy_repeated_to_vector; +using grpc_map_detail::copy_vector_to_repeated; + +namespace { + +// Mirror grpc_server_types.hpp's format_cuopt_error without pulling that heavy +// server header into this (client-linked) mapper: parse the structured +// {"error_type","msg"} payload logic_error embeds down to a clean "type: msg" +// so raw internal error detail is not sent to remote clients. +std::string sanitize_error_message(const cuopt::logic_error& e) +{ + std::string s = e.what(); + std::string msg; + auto pos = s.find("\"msg\": \""); + if (pos != std::string::npos) { + pos += 8; + auto end = s.rfind('"'); + if (end > pos) { msg = s.substr(pos, end - pos); } + } + if (msg.empty()) { msg = s; } + return cuopt::error_to_string(e.get_error_type()) + ": " + msg; +} + +cuopt::remote::RoutingSolutionStatus to_proto_status(cuopt::routing::solution_status_t s) +{ + using cuopt::routing::solution_status_t; + switch (s) { + case solution_status_t::SUCCESS: return cuopt::remote::ROUTING_SUCCESS; + case solution_status_t::INFEASIBLE: return cuopt::remote::ROUTING_INFEASIBLE; + case solution_status_t::TIMEOUT: return cuopt::remote::ROUTING_TIMEOUT; + case solution_status_t::EMPTY: return cuopt::remote::ROUTING_EMPTY; + case solution_status_t::ERROR: return cuopt::remote::ROUTING_ERROR; + } + return cuopt::remote::ROUTING_ERROR; +} + +} // namespace + +void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, + const cuopt::routing::host_assignment_t& host, + cuopt::remote::RoutingSolution* pb) +{ + pb->Clear(); + copy_vector_to_repeated(host.route, pb->mutable_route()); + copy_vector_to_repeated(host.stamp, pb->mutable_arrival_stamp()); + copy_vector_to_repeated(host.truck_id, pb->mutable_truck_id()); + copy_vector_to_repeated(host.locations, pb->mutable_locations()); + copy_vector_to_repeated(host.node_types, pb->mutable_node_types()); + copy_vector_to_repeated(host.unserviced_nodes, pb->mutable_unserviced_nodes()); + copy_vector_to_repeated(host.accepted, pb->mutable_accepted()); + + pb->set_vehicle_count(assignment.get_vehicle_count()); + pb->set_total_objective_value(assignment.get_total_objective()); + for (auto const& [obj, value] : assignment.get_objectives()) { + (*pb->mutable_objective_values())[static_cast(obj)] = value; + } + + pb->set_status(to_proto_status(assignment.get_status())); + pb->set_status_message(assignment.get_status_string()); + if (assignment.get_status() == cuopt::routing::solution_status_t::ERROR) { + try { + pb->set_error_message(sanitize_error_message(assignment.get_error_status())); + } catch (...) { + pb->set_error_message("routing solve error"); + } + } +} + +void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, + cuopt::routing::cpu_routing_solution_t& sol) +{ + copy_repeated_to_vector(pb.route(), sol.route); + copy_repeated_to_vector(pb.arrival_stamp(), sol.arrival_stamp); + copy_repeated_to_vector(pb.truck_id(), sol.truck_id); + copy_repeated_to_vector(pb.locations(), sol.locations); + copy_repeated_to_vector(pb.node_types(), sol.node_types); + copy_repeated_to_vector(pb.unserviced_nodes(), sol.unserviced_nodes); + copy_repeated_to_vector(pb.accepted(), sol.accepted); + + sol.vehicle_count = pb.vehicle_count(); + sol.total_objective_value = pb.total_objective_value(); + sol.objective_values.clear(); + for (auto const& [obj, value] : pb.objective_values()) { + sol.objective_values[static_cast(obj)] = value; + } + sol.status = static_cast(pb.status()); + sol.status_message = pb.status_message(); + sol.error_message = pb.error_message(); +} + +} // namespace routing +} // namespace cuopt diff --git a/cpp/src/grpc/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp similarity index 52% rename from cpp/src/grpc/grpc_routing_problem_mapper.hpp rename to cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp index 5fe26971d1..3721ed0ed7 100644 --- a/cpp/src/grpc/grpc_routing_problem_mapper.hpp +++ b/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp @@ -9,26 +9,13 @@ #include #include -#include -#include #include // RoutingSolution / RoutingSolutionStatus namespace cuopt { -namespace mathematical_optimization { - -void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, - cuopt::routing::cpu_routing_problem_t& problem); - -void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& problem, - cuopt::remote::RoutingProblem* pb); - -void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, - cuopt::routing::solver_settings_t& settings); - -void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, - cuopt::remote::RoutingSolverSettings* pb); +namespace routing { +// Server direction: serialize a solved assignment into a RoutingSolution proto. void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, const cuopt::routing::host_assignment_t& host, cuopt::remote::RoutingSolution* pb); @@ -37,5 +24,5 @@ void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assi void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, cuopt::routing::cpu_routing_solution_t& sol); -} // namespace mathematical_optimization +} // namespace routing } // namespace cuopt diff --git a/cpp/src/grpc/server/grpc_worker.cpp b/cpp/src/grpc/server/grpc_worker.cpp index eef240c413..250b640031 100644 --- a/cpp/src/grpc/server/grpc_worker.cpp +++ b/cpp/src/grpc/server/grpc_worker.cpp @@ -7,12 +7,17 @@ #include "grpc_incumbent_proto.hpp" #include "grpc_pipe_serialization.hpp" -#include "grpc_routing_problem_mapper.hpp" #include "grpc_server_types.hpp" +#ifdef CUOPT_ENABLE_GRPC_ROUTING +#include "routing/grpc_routing_problem_mapper.hpp" +#include "routing/grpc_routing_settings_mapper.hpp" +#include "routing/grpc_routing_solution_mapper.hpp" + #include #include #include +#endif #include #include @@ -25,9 +30,11 @@ using cuopt::mathematical_optimization::map_proto_to_mip_settings; using cuopt::mathematical_optimization::map_proto_to_pdlp_settings; using cuopt::mathematical_optimization::map_proto_to_problem; -using cuopt::mathematical_optimization::map_proto_to_routing_problem; -using cuopt::mathematical_optimization::map_proto_to_routing_settings; -using cuopt::mathematical_optimization::map_routing_solution_to_proto; +#ifdef CUOPT_ENABLE_GRPC_ROUTING +using cuopt::routing::map_proto_to_routing_problem; +using cuopt::routing::map_proto_to_routing_settings; +using cuopt::routing::map_routing_solution_to_proto; +#endif namespace { @@ -99,8 +106,10 @@ struct DeserializedJob { cuopt::mathematical_optimization::cpu_optimization_problem_t problem; cuopt::mathematical_optimization::pdlp_solver_settings_t lp_settings; cuopt::mathematical_optimization::mip_solver_settings_t mip_settings; +#ifdef CUOPT_ENABLE_GRPC_ROUTING cuopt::routing::cpu_routing_problem_t routing_problem; cuopt::routing::solver_settings_t routing_settings; +#endif bool enable_incumbents = true; bool is_vrp = false; bool success = false; @@ -369,11 +378,16 @@ static DeserializedJob read_problem_from_pipe(int worker_id, const JobQueueEntry map_proto_to_mip_settings(req.settings(), dj.mip_settings); dj.enable_incumbents = req.has_enable_incumbents() ? req.enable_incumbents() : true; } else { +#ifdef CUOPT_ENABLE_GRPC_ROUTING const auto& req = submit_request.vrp_request(); SERVER_LOG_INFO("[Worker] IPC path: UNARY VRP (%zu bytes)", request_data.size()); map_proto_to_routing_problem(req.problem(), dj.routing_problem); map_proto_to_routing_settings(req.settings(), dj.routing_settings); dj.is_vrp = true; +#else + SERVER_LOG_ERROR("[Worker] VRP request received but this build has no routing support"); + return dj; +#endif } } @@ -534,9 +548,14 @@ static SolveResult run_lp_solve(DeserializedJob& dj, // Run the routing solver on the GPU and embed the RoutingSolution proto in // ChunkedResultHeader (VRP results are typically small; no array chunking). -static SolveResult run_vrp_solve(DeserializedJob& dj, raft::handle_t& handle) +static SolveResult run_vrp_solve([[maybe_unused]] DeserializedJob& dj, + [[maybe_unused]] raft::handle_t& handle) { SolveResult sr; +#ifndef CUOPT_ENABLE_GRPC_ROUTING + sr.error_message = "ValidationError: this server was built without routing support"; + return sr; +#else try { auto [view, device_data] = dj.routing_problem.to_device(&handle); auto assignment = cuopt::routing::solve(view, dj.routing_settings); @@ -556,6 +575,7 @@ static SolveResult run_vrp_solve(DeserializedJob& dj, raft::handle_t& handle) sr.error_message = std::string("RuntimeError: ") + e.what(); } return sr; +#endif } // Publish a solve result: claim a slot in the shared-memory result_queue @@ -702,12 +722,14 @@ void worker_process(int worker_id) } if (deserialized.is_vrp || problem_category == cuopt::remote::VRP) { +#ifdef CUOPT_ENABLE_GRPC_ROUTING SERVER_LOG_INFO("[Worker] VRP problem reconstructed: %d locations, %d vehicles, %d orders", deserialized.routing_problem.num_locations, deserialized.routing_problem.fleet_size, deserialized.routing_problem.num_orders < 0 ? deserialized.routing_problem.num_locations : deserialized.routing_problem.num_orders); +#endif } else { SERVER_LOG_INFO("[Worker] Problem reconstructed: %d constraints, %d variables, %d nonzeros", deserialized.problem.get_n_constraints(), diff --git a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp index 482d3efbe8..6d9df24a15 100644 --- a/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp +++ b/cpp/tests/routing/grpc/grpc_vrp_test_driver.cpp @@ -16,7 +16,7 @@ * --json datasets/cuopt_service_data/cuopt_problem_data.json */ -#include "grpc_routing_problem_mapper.hpp" +#include "routing/grpc_routing_problem_mapper.hpp" #include @@ -218,7 +218,7 @@ int main(int argc, char** argv) auto* vrp = submit_req.mutable_vrp_request(); vrp->mutable_header()->set_version(1); vrp->mutable_header()->set_problem_category(cuopt::remote::VRP); - cuopt::mathematical_optimization::map_routing_problem_to_proto(problem, vrp->mutable_problem()); + cuopt::routing::map_routing_problem_to_proto(problem, vrp->mutable_problem()); vrp->mutable_settings()->set_time_limit(time_limit); vrp->mutable_settings()->set_verbose(verbose); From fd605da7fcbb5bd0d01eda7f74cbd355822d6ca1 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Thu, 13 Aug 2026 11:19:47 -0500 Subject: [PATCH 15/17] grpc: export the routing symbols cuopt_grpc_server links Symbol visibility controls (#1625) landed after this branch's base, so libcuopt.so is now hidden-by-default. cuopt_grpc_server links the routing proto types and mappers out of the library rather than compiling them itself, and neither was adjusted for that, so the server failed to link with undefined references to cuopt::remote::RoutingSolution and cuopt::routing::map_*. - The generated routing protos were missing from the -fvisibility=default set that already covered the LP protos (2 exported RoutingSolution symbols vs 24 for LPSolution). The property is now keyed off GRPC_PROTO_GENERATED_FILES so the two lists cannot drift again. - The routing mappers are plain functions, so they need CUOPT_EXPORT on the namespace. The LP mappers survive only because they are templates, whose weak instantiations stay exported. ci/check_symbols.sh passes: no protobuf, abseil or detail symbols leak. --- cpp/CMakeLists.txt | 4 +++- cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp | 5 +++-- cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp | 5 +++-- cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp | 5 +++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 521e693c0a..ce0375ac1e 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -628,7 +628,9 @@ if (NOT SKIP_GRPC_BUILD) # at runtime with "undefined symbol: absl::…::Mutex::Dtor". set_property(SOURCE ${GRPC_INFRA_FILES} DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} APPEND PROPERTY COMPILE_OPTIONS "-DNDEBUG") - set_property(SOURCE ${PROTO_SRCS} ${GRPC_PROTO_SRCS} ${GRPC_SERVICE_SRCS} ${DATA_PROTO_SRCS} DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} + # Generated protobuf symbols must stay visible in libcuopt.so: cuopt_grpc_server + # links them from the library rather than compiling the protos itself. + set_property(SOURCE ${GRPC_PROTO_GENERATED_FILES} DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} APPEND PROPERTY COMPILE_OPTIONS "$<$:-fvisibility=default>") endif (NOT SKIP_GRPC_BUILD) diff --git a/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp index e3ff3630c2..01b66a4ba8 100644 --- a/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp +++ b/cpp/src/grpc/routing/grpc_routing_problem_mapper.hpp @@ -7,12 +7,13 @@ #pragma once +#include #include #include namespace cuopt { -namespace routing { +namespace CUOPT_EXPORT routing { void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, cuopt::routing::cpu_routing_problem_t& problem); @@ -20,5 +21,5 @@ void map_proto_to_routing_problem(const cuopt::remote::RoutingProblem& pb, void map_routing_problem_to_proto(const cuopt::routing::cpu_routing_problem_t& problem, cuopt::remote::RoutingProblem* pb); -} // namespace routing +} // namespace CUOPT_EXPORT routing } // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp index 551ccc7527..0e870dd208 100644 --- a/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp +++ b/cpp/src/grpc/routing/grpc_routing_settings_mapper.hpp @@ -7,12 +7,13 @@ #pragma once +#include #include #include namespace cuopt { -namespace routing { +namespace CUOPT_EXPORT routing { void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& pb, cuopt::routing::solver_settings_t& settings); @@ -20,5 +21,5 @@ void map_proto_to_routing_settings(const cuopt::remote::RoutingSolverSettings& p void map_routing_settings_to_proto(const cuopt::routing::solver_settings_t& settings, cuopt::remote::RoutingSolverSettings* pb); -} // namespace routing +} // namespace CUOPT_EXPORT routing } // namespace cuopt diff --git a/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp b/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp index 3721ed0ed7..c2c651b546 100644 --- a/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp +++ b/cpp/src/grpc/routing/grpc_routing_solution_mapper.hpp @@ -7,13 +7,14 @@ #pragma once +#include #include #include #include // RoutingSolution / RoutingSolutionStatus namespace cuopt { -namespace routing { +namespace CUOPT_EXPORT routing { // Server direction: serialize a solved assignment into a RoutingSolution proto. void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assignment, @@ -24,5 +25,5 @@ void map_routing_solution_to_proto(const cuopt::routing::assignment_t& assi void map_proto_to_routing_solution(const cuopt::remote::RoutingSolution& pb, cuopt::routing::cpu_routing_solution_t& sol); -} // namespace routing +} // namespace CUOPT_EXPORT routing } // namespace cuopt From e0702ddc08373fb0a6d6f4045c3034906c0bbce7 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 14 Aug 2026 11:03:06 -0500 Subject: [PATCH 16/17] python: build the gRPC clients as one extension module cuopt.grpc.linear_programming.Client and cuopt.grpc.routing.RoutingClient were two extension modules wrapping the same C++ object, cuopt::cython::grpc_python_client_t. Build them as one unit instead. The motivation is the client/engine split: if the gRPC client is ever detached from the solver engines as a GPU-free package, two units would each need their own copy of the transport layer (connect/TLS/submit/ status/poll/chunking). One unit keeps that a single package. See the discussion on #1635. The two public classes stay separate -- their feature sets genuinely differ, with log streaming, incumbents and chunked upload being LP-only and VRP unary-only -- and both keep their import paths, which matters because Client is already released and documented. Merging the declarations also collapses a drift: the two .pxd files described the same C++ class differently. The routing copy omitted is_mip from grpc_submit_result_t, omitted most methods, and used bool where the LP copy used bint. Each was independently trusted by the compiler. There is now one authoritative declaration. Two details worth recording: - solver_settings_t is a genuine collision (the LP one and cuopt::routing's are unrelated types), resolved by aliasing the routing one on its C++ name. - The merged .pxd must not cimport libcpp bool. Cython injects a same-named .pxd into its .pyx, so that shadows the Python builtin the LP arm calls as bool(job_complete). The routing setters take bint. --- python/cuopt/cuopt/grpc/CMakeLists.txt | 3 +- python/cuopt/cuopt/grpc/__init__.py | 14 +- .../CMakeLists.txt | 10 +- python/cuopt/cuopt/grpc/client/__init__.py | 32 ++ .../grpc/{routing => client}/grpc_client.pxd | 111 ++++- .../grpc_client.pyx | 416 +++++++++++++++++- .../grpc/linear_programming/grpc_client.pxd | 103 ----- .../grpc/linear_programming/grpc_client.py | 19 + .../cuopt/cuopt/grpc/routing/CMakeLists.txt | 15 - .../cuopt/cuopt/grpc/routing/grpc_client.py | 24 + .../cuopt/cuopt/grpc/routing/grpc_client.pyx | 412 ----------------- 11 files changed, 601 insertions(+), 558 deletions(-) rename python/cuopt/cuopt/grpc/{linear_programming => client}/CMakeLists.txt (60%) create mode 100644 python/cuopt/cuopt/grpc/client/__init__.py rename python/cuopt/cuopt/grpc/{routing => client}/grpc_client.pxd (55%) rename python/cuopt/cuopt/grpc/{linear_programming => client}/grpc_client.pyx (58%) delete mode 100644 python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd create mode 100644 python/cuopt/cuopt/grpc/linear_programming/grpc_client.py delete mode 100644 python/cuopt/cuopt/grpc/routing/CMakeLists.txt create mode 100644 python/cuopt/cuopt/grpc/routing/grpc_client.py delete mode 100644 python/cuopt/cuopt/grpc/routing/grpc_client.pyx diff --git a/python/cuopt/cuopt/grpc/CMakeLists.txt b/python/cuopt/cuopt/grpc/CMakeLists.txt index 0f3c37f565..f7dd540097 100644 --- a/python/cuopt/cuopt/grpc/CMakeLists.txt +++ b/python/cuopt/cuopt/grpc/CMakeLists.txt @@ -3,5 +3,4 @@ # SPDX-License-Identifier: Apache-2.0 # cmake-format: on -add_subdirectory(linear_programming) -add_subdirectory(routing) +add_subdirectory(client) diff --git a/python/cuopt/cuopt/grpc/__init__.py b/python/cuopt/cuopt/grpc/__init__.py index 9c1baaa6f1..a7a98bcccb 100644 --- a/python/cuopt/cuopt/grpc/__init__.py +++ b/python/cuopt/cuopt/grpc/__init__.py @@ -3,13 +3,19 @@ """gRPC clients for remote cuOpt execution. -This package is the namespace for domain-specific async clients: +This package is the namespace for domain-specific clients: - :mod:`cuopt.grpc.linear_programming` — LP/MILP/QP (submit, result, incumbents) -- :mod:`cuopt.grpc.routing` — VRP/TSP/PDP (future) +- :mod:`cuopt.grpc.routing` — VRP/TSP/PDP -Shared job lifecycle (connect, status, wait, cancel, delete, logs) may live -here later as a base client type. Import domain clients explicitly, e.g. +Both are re-exports from :mod:`cuopt.grpc.client`, a single extension module. +They wrap the same C++ client object, so they are compiled as one unit; the +split into two public modules is an API boundary, not a packaging one. Keeping +one unit is what allows the client to be detached from the solver engines as a +single GPU-free package later — two units would each need their own copy of the +transport layer. + +Import domain clients explicitly, e.g. ``from cuopt.grpc.linear_programming import Client``. Do not re-export ``Client`` from this package — callers must choose the diff --git a/python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt b/python/cuopt/cuopt/grpc/client/CMakeLists.txt similarity index 60% rename from python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt rename to python/cuopt/cuopt/grpc/client/CMakeLists.txt index 0f60a2afed..907b1cafee 100644 --- a/python/cuopt/cuopt/grpc/linear_programming/CMakeLists.txt +++ b/python/cuopt/cuopt/grpc/client/CMakeLists.txt @@ -3,6 +3,9 @@ # SPDX-License-Identifier: Apache-2.0 # cmake-format: on +# One extension module for both arms. See grpc_client.pyx for why they share a +# unit: they wrap the same C++ client object, and keeping them together lets the +# client be detached from the engines as a single package later. set(cython_sources grpc_client.pyx) set(linked_libraries cuopt::cuopt) @@ -10,11 +13,6 @@ rapids_cython_create_modules( CXX SOURCE_FILES "${cython_sources}" LINKED_LIBRARIES "${linked_libraries}" - MODULE_PREFIX grpc_ + MODULE_PREFIX grpc_client_ ASSOCIATED_TARGETS cuopt ) - -foreach(_cython_target IN LISTS RAPIDS_CYTHON_CREATED_TARGETS) - target_include_directories( - ${_cython_target} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/../../linear_programming/solver") -endforeach() diff --git a/python/cuopt/cuopt/grpc/client/__init__.py b/python/cuopt/cuopt/grpc/client/__init__.py new file mode 100644 index 0000000000..d83fffffc7 --- /dev/null +++ b/python/cuopt/cuopt/grpc/client/__init__.py @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compiled gRPC clients for remote cuOpt solves (LP/MIP and routing). + +Prefer the domain-specific entry points, which re-export from here: +:mod:`cuopt.grpc.linear_programming` and :mod:`cuopt.grpc.routing`. +""" + +from cuopt.grpc.client.grpc_client import ( + Client, + GrpcError, + HANDLED_SETTERS, + JobNotReadyError, + JobStatus, + RoutingClient, + RoutingSolveError, + TlsConfig, + problem_summary, +) + +__all__ = [ + "Client", + "GrpcError", + "HANDLED_SETTERS", + "JobNotReadyError", + "JobStatus", + "RoutingClient", + "RoutingSolveError", + "TlsConfig", + "problem_summary", +] diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd b/python/cuopt/cuopt/grpc/client/grpc_client.pxd similarity index 55% rename from python/cuopt/cuopt/grpc/routing/grpc_client.pxd rename to python/cuopt/cuopt/grpc/client/grpc_client.pxd index 3099e2f3bb..2e90dea3b3 100644 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pxd +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pxd @@ -1,13 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from libc.stdint cimport int32_t, uint8_t -from libcpp cimport bool +# Single set of declarations for the one C++ client class, cuopt::cython:: +# grpc_python_client_t, which carries both the LP/MIP and the routing arms. +# These used to be declared twice -- once under grpc/linear_programming and once +# under grpc/routing -- and the two copies had already drifted (routing omitted +# is_mip and most methods, and used bool where LP used bint). + +from libc.stdint cimport int32_t, int64_t, uint8_t from libcpp.map cimport map as cpp_map from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.vector cimport vector +from cuopt.linear_programming.data_model.data_model cimport data_model_view_t +from cuopt.linear_programming.solver.solver cimport solver_ret_t +from cuopt.linear_programming.solver_settings.solver_settings cimport ( + solver_settings_t as lp_solver_settings_t, +) cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routing": # noqa cdef cppclass cpu_cost_matrix_t: @@ -93,14 +103,29 @@ cdef extern from "cuopt/routing/cpu_routing_problem.hpp" namespace "cuopt::routi cdef extern from "cuopt/routing/solver_settings.hpp" namespace "cuopt::routing": # noqa - cdef cppclass solver_settings_t[i_t, f_t]: - solver_settings_t() except + + # Aliased: cuopt::routing::solver_settings_t and the LP one above share a + # name but are unrelated types. + # bint, not libcpp bool: cimporting bool into this .pxd would shadow the + # Python builtin inside grpc_client.pyx, which the LP arm calls. + cdef cppclass routing_solver_settings_t "cuopt::routing::solver_settings_t" [i_t, f_t]: # noqa + routing_solver_settings_t() except + void set_time_limit(f_t seconds) except + - void set_verbose_mode(bool verbose) except + - void set_error_logging_mode(bool logging) except + + void set_verbose_mode(bint verbose) except + + void set_error_logging_mode(bint logging) except + + + +cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": + ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": + ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" + DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" + EXPLICIT "cuopt::cython::grpc_python_tls_mode_t::EXPLICIT" + cdef cppclass grpc_python_client_connect_options_t: + grpc_python_tls_mode_t tls_mode + string tls_root_certs + string tls_client_cert + string tls_client_key -cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": # noqa ctypedef enum grpc_job_status_t "cuopt::cython::grpc_job_status_t": QUEUED "cuopt::cython::grpc_job_status_t::QUEUED" PROCESSING "cuopt::cython::grpc_job_status_t::PROCESSING" @@ -110,9 +135,10 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" cdef cppclass grpc_submit_result_t: - bool success + bint success string error_message string job_id + bint is_mip cdef cppclass grpc_status_result_t: bint success @@ -121,18 +147,75 @@ cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": string message long long result_size_bytes + cdef cppclass grpc_result_outcome_t: + bint not_ready + bint success + string error_message + unique_ptr[solver_ret_t] solution + cdef cppclass grpc_vrp_result_outcome_t: - bool not_ready - bool success + bint not_ready + bint success string error_message cpu_routing_solution_t solution + cdef cppclass grpc_logs_result_t: + bint success + string error_message + vector[string] lines + + cdef cppclass grpc_incumbent_entry_t: + int64_t index + double objective + vector[double] assignment + + cdef cppclass grpc_incumbents_result_t: + bint success + string error_message + vector[grpc_incumbent_entry_t] incumbents + int64_t next_index + bint job_complete + + ctypedef int (*grpc_log_line_callback_t)( + const char* line, size_t line_len, int job_complete, void* user_data + ) noexcept nogil + cdef cppclass grpc_python_client_t: grpc_python_client_t(const string& host, int port) except + - bool connect(string& error_out) except + + grpc_python_client_t( + const string& host, + int port, + const grpc_python_client_connect_options_t& options, + ) except + + bint connect(string& error_out) except + + string last_error() + + # Shared job control + grpc_status_result_t status(const string& job_id) except + + grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + + bint cancel(const string& job_id, string& error_out) except + + bint delete_job(const string& job_id, string& error_out) except + + + # LP / MIP arm + grpc_submit_result_t submit( + data_model_view_t[int, double]* data_model, + lp_solver_settings_t[int, double]* settings, + bint enable_incumbents, + ) except + + grpc_result_outcome_t result(const string& job_id) except + + grpc_logs_result_t fetch_logs(const string& job_id, long long from_byte) except + + bint stream_logs( + const string& job_id, + long long from_byte, + grpc_log_line_callback_t callback, + void* user_data, + ) except + + grpc_incumbents_result_t fetch_incumbents( + const string& job_id, int64_t from_index, int max_count + ) except + + + # Routing arm grpc_submit_result_t submit_vrp( cpu_routing_problem_t* problem, - solver_settings_t[int, float]* settings) except + - grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + + routing_solver_settings_t[int, float]* settings) except + grpc_vrp_result_outcome_t result_vrp(const string& job_id) except + - bool delete_job(const string& job_id, string& error_out) except + diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx b/python/cuopt/cuopt/grpc/client/grpc_client.pyx similarity index 58% rename from python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx rename to python/cuopt/cuopt/grpc/client/grpc_client.pyx index 78a72e2413..bdfd35963e 100644 --- a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pyx +++ b/python/cuopt/cuopt/grpc/client/grpc_client.pyx @@ -6,7 +6,27 @@ # cython: embedsignature = True # cython: language_level = 3 -from cuopt.grpc.linear_programming.grpc_client cimport ( +"""Compiled gRPC clients for remote cuOpt solves. + +One extension module holding both arms, because both wrap the same C++ object +(``cuopt::cython::grpc_python_client_t``). Keeping them in one unit means the +client can later be detached from the solver engines as a single GPU-free +package rather than two that would each need their own copy of the transport. + +The two public classes stay separate -- their feature sets genuinely differ +(log streaming, incumbents and chunked upload are LP-only; VRP is unary-only) -- +and both keep their original import paths via ``cuopt.grpc.linear_programming`` +and ``cuopt.grpc.routing``. +""" + +from cuopt.grpc.client.grpc_client cimport ( + COMPLETED, + cpu_capacity_dimension_t, + cpu_cost_matrix_t, + cpu_routing_problem_t, + cpu_routing_solution_t, + cpu_uniform_break_t, + cpu_vehicle_break_t, grpc_incumbents_result_t, grpc_job_status_t, grpc_logs_result_t, @@ -17,6 +37,8 @@ from cuopt.grpc.linear_programming.grpc_client cimport ( grpc_result_outcome_t, grpc_status_result_t, grpc_submit_result_t, + grpc_vrp_result_outcome_t, + routing_solver_settings_t, ) from cuopt.linear_programming.data_model.data_model_wrapper cimport DataModel from cuopt.linear_programming.solver.solver cimport solver_ret_t @@ -31,21 +53,29 @@ from cuopt.linear_programming.solver_settings.solver_settings cimport ( SolverSettings, ) +from cython.operator cimport dereference as deref, postincrement as postinc + from enum import IntEnum import math import threading import time import warnings -from libc.stdint cimport int64_t +from libc.stdint cimport int32_t, int64_t, uint8_t from libc.stddef cimport size_t +from libcpp.map cimport map as cpp_map from libcpp.memory cimport unique_ptr from libcpp.string cimport string from libcpp.utility cimport move +from libcpp.vector cimport vector import numpy as np +# ============================================================================= +# LP / MIP arm +# ============================================================================= + class JobStatus(IntEnum): QUEUED = grpc_job_status_t.QUEUED PROCESSING = grpc_job_status_t.PROCESSING @@ -647,3 +677,385 @@ def _is_mip(var_types): vt == "I" or vt == b"I" or vt == "S" or vt == b"S" for vt in var_types ) + + +# ============================================================================= +# Routing (VRP) arm +# ============================================================================= + +class RoutingSolveError(RuntimeError): + """A remote VRP job failed or returned no routing solution.""" + + +# Recorded setters that _populate() maps into cpu_routing_problem_t. Must match +# the dispatch in _populate; test_grpc_serialization asserts this covers every +# name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. +HANDLED_SETTERS = frozenset({ + "add_cost_matrix", + "add_transit_time_matrix", + "set_order_time_windows", + "set_vehicle_time_windows", + "set_vehicle_locations", + "set_pickup_delivery_pairs", + "add_capacity_dimension", + "set_order_service_times", + "add_vehicle_order_match", + "add_order_vehicle_match", + "add_order_precedence", + "add_break_dimension", + "add_vehicle_break", + "set_objective_function", + "add_initial_solutions", + "set_min_vehicles", + "set_order_locations", + "set_order_prizes", + "set_vehicle_types", + "set_drop_return_trips", + "set_skip_first_trips", + "set_vehicle_max_costs", + "set_vehicle_max_times", + "set_vehicle_fixed_costs", + "set_break_locations", +}) + + +def _to_host(x): + """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" + if isinstance(x, np.ndarray): + return x + root = type(x).__module__.split(".", 1)[0] + if root in ("pandas", "cudf"): + return x.to_numpy() + if root == "cupy": + return x.get() + return np.asarray(x) + + +# --- numpy -> std::vector fillers ------------------------------------------ + +cdef void _fill_i32(vector[int32_t]& v, arr) except *: + cdef int32_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.int32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_u8(vector[uint8_t]& v, arr) except *: + cdef uint8_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.uint8).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +cdef void _fill_f32(vector[float]& v, arr) except *: + cdef float[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.float32).ravel() + cdef Py_ssize_t n = mv.shape[0] + cdef Py_ssize_t i + v.resize(n) + for i in range(n): + v[i] = mv[i] + + +# --- std::vector -> numpy --------------------------------------------------- + +cdef _i32_to_np(const vector[int32_t]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.int32) + cdef int32_t[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +cdef _f64_to_np(const vector[double]& v): + cdef Py_ssize_t n = v.size() + out = np.empty(n, dtype=np.float64) + cdef double[::1] mv = out + cdef Py_ssize_t i + for i in range(n): + mv[i] = v[i] + return out + + +# --- DataModel IR -> cpu_routing_problem_t ---------------------------------- + +cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: + # _fill_f32 already casts to float32 and C-order ravels (row-major). + cdef cpu_cost_matrix_t cm + cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) + _fill_f32(cm.matrix, args[0]) + dst.push_back(cm) + + +cdef void _populate(cpu_routing_problem_t& p, data_model) except *: + n_loc, fleet, n_ord = data_model._init_args + p.num_locations = int(n_loc) + p.fleet_size = int(fleet) + p.num_orders = (int(n_loc) if int(n_ord) == -1 else int(n_ord)) + + cdef vector[int32_t] tmp_i + cdef cpu_capacity_dimension_t cap + cdef cpu_uniform_break_t ub + cdef cpu_vehicle_break_t vb + cdef int32_t vid + + for name, args, _ in data_model._calls: + if name == "add_cost_matrix": + _add_matrix(p.cost_matrices, args) + elif name == "add_transit_time_matrix": + _add_matrix(p.transit_time_matrices, args) + elif name == "set_order_time_windows": + _fill_i32(p.order_tw_earliest, args[0]) + _fill_i32(p.order_tw_latest, args[1]) + elif name == "set_vehicle_time_windows": + _fill_i32(p.vehicle_tw_earliest, args[0]) + _fill_i32(p.vehicle_tw_latest, args[1]) + elif name == "set_vehicle_locations": + _fill_i32(p.vehicle_start_locations, args[0]) + _fill_i32(p.vehicle_return_locations, args[1]) + elif name == "set_pickup_delivery_pairs": + _fill_i32(p.pickup_indices, args[0]) + _fill_i32(p.delivery_indices, args[1]) + elif name == "add_capacity_dimension": + cap = cpu_capacity_dimension_t() + cap.name = str(args[0]).encode("utf-8") + _fill_i32(cap.demand, args[1]) + _fill_i32(cap.capacity, args[2]) + p.capacity_dimensions.push_back(cap) + elif name == "set_order_service_times": + vid = (int(args[1]) if len(args) > 1 else -1) + tmp_i.clear() + _fill_i32(tmp_i, args[0]) + p.order_service_times[vid] = tmp_i + elif name == "add_vehicle_order_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.vehicle_order_match[vid] = tmp_i + elif name == "add_order_vehicle_match": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_vehicle_match[vid] = tmp_i + elif name == "add_order_precedence": + vid = int(args[0]) + tmp_i.clear() + _fill_i32(tmp_i, args[1]) + p.order_precedence[vid] = tmp_i + elif name == "add_break_dimension": + ub = cpu_uniform_break_t() + _fill_i32(ub.earliest, args[0]) + _fill_i32(ub.latest, args[1]) + _fill_i32(ub.duration, args[2]) + p.uniform_breaks.push_back(ub) + elif name == "add_vehicle_break": + vid = int(args[0]) + vb = cpu_vehicle_break_t() + vb.earliest = int(args[1]) + vb.latest = int(args[2]) + vb.duration = int(args[3]) + if len(args) > 4 and args[4] is not None: + _fill_i32(vb.locations, args[4]) + p.vehicle_breaks[vid].push_back(vb) + elif name == "set_objective_function": + _fill_i32(p.objectives, args[0]) + _fill_f32(p.objective_weights, args[1]) + elif name == "add_initial_solutions": + _fill_i32(p.initial_solutions.vehicle_ids, args[0]) + _fill_i32(p.initial_solutions.routes, args[1]) + _fill_i32(p.initial_solutions.types, args[2]) + _fill_i32(p.initial_solutions.sol_offsets, args[3]) + elif name == "set_min_vehicles": + p.min_vehicles = int(args[0]) + elif name == "set_order_locations": + _fill_i32(p.order_locations, args[0]) + elif name == "set_order_prizes": + _fill_f32(p.order_prizes, args[0]) + elif name == "set_vehicle_types": + _fill_u8(p.vehicle_types, args[0]) + elif name == "set_drop_return_trips": + _fill_u8(p.drop_return_trips, args[0]) + elif name == "set_skip_first_trips": + _fill_u8(p.skip_first_trips, args[0]) + elif name == "set_vehicle_max_costs": + _fill_f32(p.vehicle_max_costs, args[0]) + elif name == "set_vehicle_max_times": + _fill_f32(p.vehicle_max_times, args[0]) + elif name == "set_vehicle_fixed_costs": + _fill_f32(p.vehicle_fixed_costs, args[0]) + elif name == "set_break_locations": + _fill_i32(p.break_locations, args[0]) + else: + raise KeyError( + f"no VRP gRPC mapping for recorded setter {name!r}; add a case " + "to cuopt.grpc.routing.grpc_client._populate" + ) + + +def problem_summary(data_model): + """Populate a ``cpu_routing_problem_t`` from ``data_model`` and return a + ``{field: size}`` summary. Runs the exact ``_populate`` path used by + ``submit`` (so a mis-mapped or unmapped setter fails here too), without a + server. Intended for tests. + """ + cdef cpu_routing_problem_t p + _populate(p, data_model) + return { + "num_locations": int(p.num_locations), + "fleet_size": int(p.fleet_size), + "num_orders": int(p.num_orders), + "min_vehicles": int(p.min_vehicles), + "cost_matrices": p.cost_matrices.size(), + "transit_time_matrices": p.transit_time_matrices.size(), + "vehicle_start_locations": p.vehicle_start_locations.size(), + "vehicle_return_locations": p.vehicle_return_locations.size(), + "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), + "vehicle_tw_latest": p.vehicle_tw_latest.size(), + "vehicle_types": p.vehicle_types.size(), + "drop_return_trips": p.drop_return_trips.size(), + "skip_first_trips": p.skip_first_trips.size(), + "vehicle_max_costs": p.vehicle_max_costs.size(), + "vehicle_max_times": p.vehicle_max_times.size(), + "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), + "order_locations": p.order_locations.size(), + "order_tw_earliest": p.order_tw_earliest.size(), + "order_tw_latest": p.order_tw_latest.size(), + "order_prizes": p.order_prizes.size(), + "order_service_times": p.order_service_times.size(), + "pickup_indices": p.pickup_indices.size(), + "delivery_indices": p.delivery_indices.size(), + "capacity_dimensions": p.capacity_dimensions.size(), + "break_locations": p.break_locations.size(), + "uniform_breaks": p.uniform_breaks.size(), + "vehicle_breaks": p.vehicle_breaks.size(), + "vehicle_order_match": p.vehicle_order_match.size(), + "order_vehicle_match": p.order_vehicle_match.size(), + "order_precedence": p.order_precedence.size(), + "objectives": p.objectives.size(), + "objective_weights": p.objective_weights.size(), + "initial_solutions_routes": p.initial_solutions.routes.size(), + } + + +cdef _solution_to_py(cpu_routing_solution_t s): + cdef dict objectives = {} + cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() + while it != s.objective_values.end(): + objectives[int(deref(it).first)] = float(deref(it).second) + postinc(it) + return { + "status": int(s.status), + "status_message": s.status_message.decode("utf-8"), + "error_message": s.error_message.decode("utf-8"), + "vehicle_count": int(s.vehicle_count), + "total_objective_value": float(s.total_objective_value), + "objective_values": objectives, + "route": _i32_to_np(s.route), + "truck_id": _i32_to_np(s.truck_id), + "locations": _i32_to_np(s.locations), + "node_types": _i32_to_np(s.node_types), + "arrival_stamp": _f64_to_np(s.arrival_stamp), + "unserviced_nodes": _i32_to_np(s.unserviced_nodes), + "accepted": _i32_to_np(s.accepted), + } + + +cdef class RoutingClient: + """Client for solving VRP problems on a remote cuOpt gRPC server.""" + + cdef unique_ptr[grpc_python_client_t] _client + + def __cinit__(self, str target="localhost:50051"): + host, _, port = target.rpartition(":") + if not host: + host, port = target, "50051" + cdef string host_cpp = host.encode("utf-8") + cdef string err + self._client.reset(new grpc_python_client_t(host_cpp, int(port))) + if not self._client.get().connect(err): + raise RoutingSolveError( + "failed to connect: " + err.decode("utf-8") + ) + + cdef _apply_settings(self, routing_solver_settings_t[int, float]& s, settings): + if settings is None: + return + if isinstance(settings, dict): + tl = settings.get("time_limit") + if tl is not None: + s.set_time_limit(float(tl)) + return + get_time_limit = getattr(settings, "get_time_limit", None) + if get_time_limit is not None: + tl = get_time_limit() + if tl is not None: + s.set_time_limit(float(tl)) + + def submit(self, data_model, settings=None): + """Serialize and submit a VRP problem; return its ``job_id``.""" + cdef cpu_routing_problem_t problem + cdef routing_solver_settings_t[int, float] cpp_settings + _populate(problem, data_model) + self._apply_settings(cpp_settings, settings) + cdef grpc_submit_result_t sub = self._client.get().submit_vrp( + &problem, &cpp_settings + ) + if not sub.success: + raise RoutingSolveError(sub.error_message.decode("utf-8")) + return sub.job_id.decode("utf-8") + + def wait(self, str job_id, int timeout=0): + """Block until the job finishes; return the terminal status int. + + Raises ``RoutingSolveError`` if the wait itself fails (e.g. transport + error or unknown job), mirroring the LP/MILP client. + """ + cdef grpc_status_result_t st = self._client.get().wait( + job_id.encode("utf-8"), timeout + ) + if not st.success: + raise RoutingSolveError(st.error_message.decode("utf-8")) + return st.status + + def result(self, str job_id): + """Fetch and parse the routing solution for a completed job. + + Returns ``None`` if the job is still in flight (mirrors the LP client). + """ + cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( + job_id.encode("utf-8") + ) + if out.not_ready: + return None + if not out.success: + raise RoutingSolveError(out.error_message.decode("utf-8")) + return _solution_to_py(out.solution) + + def delete(self, str job_id): + """Delete a job's server-side result; raise ``RoutingSolveError`` on failure.""" + cdef string err + if not self._client.get().delete_job(job_id.encode("utf-8"), err): + raise RoutingSolveError(err.decode("utf-8")) + + def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): + """Submit, wait, and return the solution (the common path).""" + job_id = self.submit(data_model, settings) + try: + status = self.wait(job_id, timeout) + if status != COMPLETED: + # A non-completed terminal status means the solve failed; + # result() surfaces the server's error_message. If it somehow + # doesn't raise, fall back to a status-only message. + self.result(job_id) + raise RoutingSolveError( + f"job {job_id} did not complete (status {status})" + ) + return self.result(job_id) + finally: + if delete: + self.delete(job_id) diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd deleted file mode 100644 index 8677266f13..0000000000 --- a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.pxd +++ /dev/null @@ -1,103 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from libc.stdint cimport int64_t -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string -from libcpp.vector cimport vector - -from cuopt.linear_programming.data_model.data_model cimport data_model_view_t -from cuopt.linear_programming.solver.solver cimport solver_ret_t -from cuopt.linear_programming.solver_settings.solver_settings cimport ( - solver_settings_t, -) - -cdef extern from "cuopt/grpc/cython_grpc_client.hpp" namespace "cuopt::cython": - ctypedef enum grpc_python_tls_mode_t "cuopt::cython::grpc_python_tls_mode_t": - ENV "cuopt::cython::grpc_python_tls_mode_t::ENV" - DISABLED "cuopt::cython::grpc_python_tls_mode_t::DISABLED" - EXPLICIT "cuopt::cython::grpc_python_tls_mode_t::EXPLICIT" - - cdef cppclass grpc_python_client_connect_options_t: - grpc_python_tls_mode_t tls_mode - string tls_root_certs - string tls_client_cert - string tls_client_key - - ctypedef enum grpc_job_status_t "cuopt::cython::grpc_job_status_t": - QUEUED "cuopt::cython::grpc_job_status_t::QUEUED" - PROCESSING "cuopt::cython::grpc_job_status_t::PROCESSING" - COMPLETED "cuopt::cython::grpc_job_status_t::COMPLETED" - FAILED "cuopt::cython::grpc_job_status_t::FAILED" - CANCELLED "cuopt::cython::grpc_job_status_t::CANCELLED" - NOT_FOUND "cuopt::cython::grpc_job_status_t::NOT_FOUND" - - cdef cppclass grpc_submit_result_t: - bint success - string error_message - string job_id - bint is_mip - - cdef cppclass grpc_status_result_t: - bint success - string error_message - grpc_job_status_t status - string message - long long result_size_bytes - - cdef cppclass grpc_result_outcome_t: - bint not_ready - bint success - string error_message - unique_ptr[solver_ret_t] solution - - cdef cppclass grpc_logs_result_t: - bint success - string error_message - vector[string] lines - - cdef cppclass grpc_incumbent_entry_t: - int64_t index - double objective - vector[double] assignment - - cdef cppclass grpc_incumbents_result_t: - bint success - string error_message - vector[grpc_incumbent_entry_t] incumbents - int64_t next_index - bint job_complete - - ctypedef int (*grpc_log_line_callback_t)( - const char* line, size_t line_len, int job_complete, void* user_data - ) noexcept nogil - - cdef cppclass grpc_python_client_t: - grpc_python_client_t(const string& host, int port) except + - grpc_python_client_t( - const string& host, - int port, - const grpc_python_client_connect_options_t& options, - ) except + - bint connect(string& error_out) - grpc_submit_result_t submit( - data_model_view_t[int, double]* data_model, - solver_settings_t[int, double]* settings, - bint enable_incumbents, - ) except + - grpc_status_result_t status(const string& job_id) except + - grpc_status_result_t wait(const string& job_id, int timeout_seconds) except + - bint cancel(const string& job_id, string& error_out) except + - bint delete_job(const string& job_id, string& error_out) except + - grpc_result_outcome_t result(const string& job_id) except + - grpc_logs_result_t fetch_logs(const string& job_id, long long from_byte) except + - bint stream_logs( - const string& job_id, - long long from_byte, - grpc_log_line_callback_t callback, - void* user_data, - ) except + - grpc_incumbents_result_t fetch_incumbents( - const string& job_id, int64_t from_index, int max_count - ) except + - string last_error() diff --git a/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py new file mode 100644 index 0000000000..e102cff060 --- /dev/null +++ b/python/cuopt/cuopt/grpc/linear_programming/grpc_client.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compatibility shim. + +The LP/MIP and routing clients were merged into a single extension module +(:mod:`cuopt.grpc.client.grpc_client`). This module keeps the original import +path working. +""" + +from cuopt.grpc.client.grpc_client import ( # noqa: F401 + Client, + GrpcError, + JobNotReadyError, + JobStatus, + TlsConfig, +) + +__all__ = ["Client", "GrpcError", "JobNotReadyError", "JobStatus", "TlsConfig"] diff --git a/python/cuopt/cuopt/grpc/routing/CMakeLists.txt b/python/cuopt/cuopt/grpc/routing/CMakeLists.txt deleted file mode 100644 index 620e038de2..0000000000 --- a/python/cuopt/cuopt/grpc/routing/CMakeLists.txt +++ /dev/null @@ -1,15 +0,0 @@ -# cmake-format: off -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -# cmake-format: on - -set(cython_sources grpc_client.pyx) -set(linked_libraries cuopt::cuopt) - -rapids_cython_create_modules( - CXX - SOURCE_FILES "${cython_sources}" - LINKED_LIBRARIES "${linked_libraries}" - MODULE_PREFIX grpc_routing_ - ASSOCIATED_TARGETS cuopt -) diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.py b/python/cuopt/cuopt/grpc/routing/grpc_client.py new file mode 100644 index 0000000000..38c37dbdfa --- /dev/null +++ b/python/cuopt/cuopt/grpc/routing/grpc_client.py @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Compatibility shim. + +The routing and LP/MIP clients were merged into a single extension module +(:mod:`cuopt.grpc.client.grpc_client`). This module keeps the original import +path working, including the names the serialization test reaches for +(``HANDLED_SETTERS``, ``problem_summary``). +""" + +from cuopt.grpc.client.grpc_client import ( # noqa: F401 + HANDLED_SETTERS, + RoutingClient, + RoutingSolveError, + problem_summary, +) + +__all__ = [ + "HANDLED_SETTERS", + "RoutingClient", + "RoutingSolveError", + "problem_summary", +] diff --git a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx b/python/cuopt/cuopt/grpc/routing/grpc_client.pyx deleted file mode 100644 index 7549099c2e..0000000000 --- a/python/cuopt/cuopt/grpc/routing/grpc_client.pyx +++ /dev/null @@ -1,412 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -# distutils: language = c++ - -"""Compiled gRPC client for remote VRP solves, consistent with the LP/MILP -client. Builds a host ``cpu_routing_problem_t`` from a routing ``DataModel`` -(walking its store-then-build IR) and serializes it in C++; no pure-Python -protobuf path. -""" - -from cython.operator cimport dereference as deref, postincrement as postinc -from libc.stdint cimport int32_t, uint8_t -from libcpp.map cimport map as cpp_map -from libcpp.memory cimport unique_ptr -from libcpp.string cimport string -from libcpp.vector cimport vector - -import numpy as np - -from cuopt.grpc.routing.grpc_client cimport ( - COMPLETED, - cpu_capacity_dimension_t, - cpu_cost_matrix_t, - cpu_routing_problem_t, - cpu_routing_solution_t, - cpu_uniform_break_t, - cpu_vehicle_break_t, - grpc_python_client_t, - grpc_status_result_t, - grpc_submit_result_t, - grpc_vrp_result_outcome_t, - solver_settings_t, -) - - -class RoutingSolveError(RuntimeError): - """A remote VRP job failed or returned no routing solution.""" - - -# Recorded setters that _populate() maps into cpu_routing_problem_t. Must match -# the dispatch in _populate; test_grpc_serialization asserts this covers every -# name in cuopt.routing._deferred._SETTERS so a new setter cannot be missed. -HANDLED_SETTERS = frozenset({ - "add_cost_matrix", - "add_transit_time_matrix", - "set_order_time_windows", - "set_vehicle_time_windows", - "set_vehicle_locations", - "set_pickup_delivery_pairs", - "add_capacity_dimension", - "set_order_service_times", - "add_vehicle_order_match", - "add_order_vehicle_match", - "add_order_precedence", - "add_break_dimension", - "add_vehicle_break", - "set_objective_function", - "add_initial_solutions", - "set_min_vehicles", - "set_order_locations", - "set_order_prizes", - "set_vehicle_types", - "set_drop_return_trips", - "set_skip_first_trips", - "set_vehicle_max_costs", - "set_vehicle_max_times", - "set_vehicle_fixed_costs", - "set_break_locations", -}) - - -def _to_host(x): - """Return a host numpy array from numpy/pandas/cuDF/cupy/list input.""" - if isinstance(x, np.ndarray): - return x - root = type(x).__module__.split(".", 1)[0] - if root in ("pandas", "cudf"): - return x.to_numpy() - if root == "cupy": - return x.get() - return np.asarray(x) - - -# --- numpy -> std::vector fillers ------------------------------------------ - -cdef void _fill_i32(vector[int32_t]& v, arr) except *: - cdef int32_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.int32).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -cdef void _fill_u8(vector[uint8_t]& v, arr) except *: - cdef uint8_t[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.uint8).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -cdef void _fill_f32(vector[float]& v, arr) except *: - cdef float[::1] mv = np.ascontiguousarray(_to_host(arr), dtype=np.float32).ravel() - cdef Py_ssize_t n = mv.shape[0] - cdef Py_ssize_t i - v.resize(n) - for i in range(n): - v[i] = mv[i] - - -# --- std::vector -> numpy --------------------------------------------------- - -cdef _i32_to_np(const vector[int32_t]& v): - cdef Py_ssize_t n = v.size() - out = np.empty(n, dtype=np.int32) - cdef int32_t[::1] mv = out - cdef Py_ssize_t i - for i in range(n): - mv[i] = v[i] - return out - - -cdef _f64_to_np(const vector[double]& v): - cdef Py_ssize_t n = v.size() - out = np.empty(n, dtype=np.float64) - cdef double[::1] mv = out - cdef Py_ssize_t i - for i in range(n): - mv[i] = v[i] - return out - - -# --- DataModel IR -> cpu_routing_problem_t ---------------------------------- - -cdef void _add_matrix(vector[cpu_cost_matrix_t]& dst, args) except *: - # _fill_f32 already casts to float32 and C-order ravels (row-major). - cdef cpu_cost_matrix_t cm - cm.vehicle_type = (int(args[1]) if len(args) > 1 else 0) - _fill_f32(cm.matrix, args[0]) - dst.push_back(cm) - - -cdef void _populate(cpu_routing_problem_t& p, data_model) except *: - n_loc, fleet, n_ord = data_model._init_args - p.num_locations = int(n_loc) - p.fleet_size = int(fleet) - p.num_orders = (int(n_loc) if int(n_ord) == -1 else int(n_ord)) - - cdef vector[int32_t] tmp_i - cdef cpu_capacity_dimension_t cap - cdef cpu_uniform_break_t ub - cdef cpu_vehicle_break_t vb - cdef int32_t vid - - for name, args, _ in data_model._calls: - if name == "add_cost_matrix": - _add_matrix(p.cost_matrices, args) - elif name == "add_transit_time_matrix": - _add_matrix(p.transit_time_matrices, args) - elif name == "set_order_time_windows": - _fill_i32(p.order_tw_earliest, args[0]) - _fill_i32(p.order_tw_latest, args[1]) - elif name == "set_vehicle_time_windows": - _fill_i32(p.vehicle_tw_earliest, args[0]) - _fill_i32(p.vehicle_tw_latest, args[1]) - elif name == "set_vehicle_locations": - _fill_i32(p.vehicle_start_locations, args[0]) - _fill_i32(p.vehicle_return_locations, args[1]) - elif name == "set_pickup_delivery_pairs": - _fill_i32(p.pickup_indices, args[0]) - _fill_i32(p.delivery_indices, args[1]) - elif name == "add_capacity_dimension": - cap = cpu_capacity_dimension_t() - cap.name = str(args[0]).encode("utf-8") - _fill_i32(cap.demand, args[1]) - _fill_i32(cap.capacity, args[2]) - p.capacity_dimensions.push_back(cap) - elif name == "set_order_service_times": - vid = (int(args[1]) if len(args) > 1 else -1) - tmp_i.clear() - _fill_i32(tmp_i, args[0]) - p.order_service_times[vid] = tmp_i - elif name == "add_vehicle_order_match": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.vehicle_order_match[vid] = tmp_i - elif name == "add_order_vehicle_match": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.order_vehicle_match[vid] = tmp_i - elif name == "add_order_precedence": - vid = int(args[0]) - tmp_i.clear() - _fill_i32(tmp_i, args[1]) - p.order_precedence[vid] = tmp_i - elif name == "add_break_dimension": - ub = cpu_uniform_break_t() - _fill_i32(ub.earliest, args[0]) - _fill_i32(ub.latest, args[1]) - _fill_i32(ub.duration, args[2]) - p.uniform_breaks.push_back(ub) - elif name == "add_vehicle_break": - vid = int(args[0]) - vb = cpu_vehicle_break_t() - vb.earliest = int(args[1]) - vb.latest = int(args[2]) - vb.duration = int(args[3]) - if len(args) > 4 and args[4] is not None: - _fill_i32(vb.locations, args[4]) - p.vehicle_breaks[vid].push_back(vb) - elif name == "set_objective_function": - _fill_i32(p.objectives, args[0]) - _fill_f32(p.objective_weights, args[1]) - elif name == "add_initial_solutions": - _fill_i32(p.initial_solutions.vehicle_ids, args[0]) - _fill_i32(p.initial_solutions.routes, args[1]) - _fill_i32(p.initial_solutions.types, args[2]) - _fill_i32(p.initial_solutions.sol_offsets, args[3]) - elif name == "set_min_vehicles": - p.min_vehicles = int(args[0]) - elif name == "set_order_locations": - _fill_i32(p.order_locations, args[0]) - elif name == "set_order_prizes": - _fill_f32(p.order_prizes, args[0]) - elif name == "set_vehicle_types": - _fill_u8(p.vehicle_types, args[0]) - elif name == "set_drop_return_trips": - _fill_u8(p.drop_return_trips, args[0]) - elif name == "set_skip_first_trips": - _fill_u8(p.skip_first_trips, args[0]) - elif name == "set_vehicle_max_costs": - _fill_f32(p.vehicle_max_costs, args[0]) - elif name == "set_vehicle_max_times": - _fill_f32(p.vehicle_max_times, args[0]) - elif name == "set_vehicle_fixed_costs": - _fill_f32(p.vehicle_fixed_costs, args[0]) - elif name == "set_break_locations": - _fill_i32(p.break_locations, args[0]) - else: - raise KeyError( - f"no VRP gRPC mapping for recorded setter {name!r}; add a case " - "to cuopt.grpc.routing.grpc_client._populate" - ) - - -def problem_summary(data_model): - """Populate a ``cpu_routing_problem_t`` from ``data_model`` and return a - ``{field: size}`` summary. Runs the exact ``_populate`` path used by - ``submit`` (so a mis-mapped or unmapped setter fails here too), without a - server. Intended for tests. - """ - cdef cpu_routing_problem_t p - _populate(p, data_model) - return { - "num_locations": int(p.num_locations), - "fleet_size": int(p.fleet_size), - "num_orders": int(p.num_orders), - "min_vehicles": int(p.min_vehicles), - "cost_matrices": p.cost_matrices.size(), - "transit_time_matrices": p.transit_time_matrices.size(), - "vehicle_start_locations": p.vehicle_start_locations.size(), - "vehicle_return_locations": p.vehicle_return_locations.size(), - "vehicle_tw_earliest": p.vehicle_tw_earliest.size(), - "vehicle_tw_latest": p.vehicle_tw_latest.size(), - "vehicle_types": p.vehicle_types.size(), - "drop_return_trips": p.drop_return_trips.size(), - "skip_first_trips": p.skip_first_trips.size(), - "vehicle_max_costs": p.vehicle_max_costs.size(), - "vehicle_max_times": p.vehicle_max_times.size(), - "vehicle_fixed_costs": p.vehicle_fixed_costs.size(), - "order_locations": p.order_locations.size(), - "order_tw_earliest": p.order_tw_earliest.size(), - "order_tw_latest": p.order_tw_latest.size(), - "order_prizes": p.order_prizes.size(), - "order_service_times": p.order_service_times.size(), - "pickup_indices": p.pickup_indices.size(), - "delivery_indices": p.delivery_indices.size(), - "capacity_dimensions": p.capacity_dimensions.size(), - "break_locations": p.break_locations.size(), - "uniform_breaks": p.uniform_breaks.size(), - "vehicle_breaks": p.vehicle_breaks.size(), - "vehicle_order_match": p.vehicle_order_match.size(), - "order_vehicle_match": p.order_vehicle_match.size(), - "order_precedence": p.order_precedence.size(), - "objectives": p.objectives.size(), - "objective_weights": p.objective_weights.size(), - "initial_solutions_routes": p.initial_solutions.routes.size(), - } - - -cdef _solution_to_py(cpu_routing_solution_t s): - cdef dict objectives = {} - cdef cpp_map[int32_t, double].iterator it = s.objective_values.begin() - while it != s.objective_values.end(): - objectives[int(deref(it).first)] = float(deref(it).second) - postinc(it) - return { - "status": int(s.status), - "status_message": s.status_message.decode("utf-8"), - "error_message": s.error_message.decode("utf-8"), - "vehicle_count": int(s.vehicle_count), - "total_objective_value": float(s.total_objective_value), - "objective_values": objectives, - "route": _i32_to_np(s.route), - "truck_id": _i32_to_np(s.truck_id), - "locations": _i32_to_np(s.locations), - "node_types": _i32_to_np(s.node_types), - "arrival_stamp": _f64_to_np(s.arrival_stamp), - "unserviced_nodes": _i32_to_np(s.unserviced_nodes), - "accepted": _i32_to_np(s.accepted), - } - - -cdef class RoutingClient: - """Client for solving VRP problems on a remote cuOpt gRPC server.""" - - cdef unique_ptr[grpc_python_client_t] _client - - def __cinit__(self, str target="localhost:50051"): - host, _, port = target.rpartition(":") - if not host: - host, port = target, "50051" - cdef string host_cpp = host.encode("utf-8") - cdef string err - self._client.reset(new grpc_python_client_t(host_cpp, int(port))) - if not self._client.get().connect(err): - raise RoutingSolveError( - "failed to connect: " + err.decode("utf-8") - ) - - cdef _apply_settings(self, solver_settings_t[int, float]& s, settings): - if settings is None: - return - if isinstance(settings, dict): - tl = settings.get("time_limit") - if tl is not None: - s.set_time_limit(float(tl)) - return - get_time_limit = getattr(settings, "get_time_limit", None) - if get_time_limit is not None: - tl = get_time_limit() - if tl is not None: - s.set_time_limit(float(tl)) - - def submit(self, data_model, settings=None): - """Serialize and submit a VRP problem; return its ``job_id``.""" - cdef cpu_routing_problem_t problem - cdef solver_settings_t[int, float] cpp_settings - _populate(problem, data_model) - self._apply_settings(cpp_settings, settings) - cdef grpc_submit_result_t sub = self._client.get().submit_vrp( - &problem, &cpp_settings - ) - if not sub.success: - raise RoutingSolveError(sub.error_message.decode("utf-8")) - return sub.job_id.decode("utf-8") - - def wait(self, str job_id, int timeout=0): - """Block until the job finishes; return the terminal status int. - - Raises ``RoutingSolveError`` if the wait itself fails (e.g. transport - error or unknown job), mirroring the LP/MILP client. - """ - cdef grpc_status_result_t st = self._client.get().wait( - job_id.encode("utf-8"), timeout - ) - if not st.success: - raise RoutingSolveError(st.error_message.decode("utf-8")) - return st.status - - def result(self, str job_id): - """Fetch and parse the routing solution for a completed job. - - Returns ``None`` if the job is still in flight (mirrors the LP client). - """ - cdef grpc_vrp_result_outcome_t out = self._client.get().result_vrp( - job_id.encode("utf-8") - ) - if out.not_ready: - return None - if not out.success: - raise RoutingSolveError(out.error_message.decode("utf-8")) - return _solution_to_py(out.solution) - - def delete(self, str job_id): - """Delete a job's server-side result; raise ``RoutingSolveError`` on failure.""" - cdef string err - if not self._client.get().delete_job(job_id.encode("utf-8"), err): - raise RoutingSolveError(err.decode("utf-8")) - - def solve(self, data_model, settings=None, *, int timeout=0, bint delete=True): - """Submit, wait, and return the solution (the common path).""" - job_id = self.submit(data_model, settings) - try: - status = self.wait(job_id, timeout) - if status != COMPLETED: - # A non-completed terminal status means the solve failed; - # result() surfaces the server's error_message. If it somehow - # doesn't raise, fall back to a status-only message. - self.result(job_id) - raise RoutingSolveError( - f"job {job_id} did not complete (status {status})" - ) - return self.result(job_id) - finally: - if delete: - self.delete(job_id) From 4183bd53d1ab2ed8421be5870bf82e8680b97101 Mon Sep 17 00:00:00 2001 From: Ramakrishna Prabhu Date: Fri, 14 Aug 2026 15:31:07 -0500 Subject: [PATCH 17/17] build(cmake): scope the routing gRPC define and fetch nlohmann_json via CPM Addresses review feedback from @jameslamb on #1597. CUOPT_ENABLE_GRPC_ROUTING was set with add_compile_definitions, which puts -D on every target in the directory tree. grpc_worker.cpp is the only source that tests the macro, and it belongs only to cuopt_grpc_server, so the define is now attached to that target. The CMake variable still gates which sources are compiled. Verified in build.ninja: the define now appears on cuopt_grpc_server's 8 sources and nowhere in cuopt_objs. The routing gRPC test driver looked for nlohmann_json with find_package() falling back to find_path(), and returned early -- so the driver silently vanished from the build -- when neither found it. It now comes from cmake/thirdparty/get_nlohmann_json.cmake via rapids_cpm_find, matching rapidsai/cuvs, which is the only RAPIDS repo that carries this module. The pin (3.12.0) matches both cuVS and the nlohmann_json conda package, so an existing install is reused rather than fetched: configure reports "CPM: Using local package nlohmann_json@3.12.0". --- cpp/CMakeLists.txt | 11 +++++- cpp/cmake/thirdparty/get_nlohmann_json.cmake | 35 ++++++++++++++++++++ cpp/tests/routing/grpc/CMakeLists.txt | 19 ++--------- 3 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 cpp/cmake/thirdparty/get_nlohmann_json.cmake diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index ce0375ac1e..f7874ebbd2 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -399,9 +399,12 @@ if (NOT SKIP_GRPC_BUILD) # protocol (including the routing protos) is always generated so LP-only # builds still speak the same protocol; only the mappers and the VRP # client/worker paths are dropped. + # + # This only gates which sources are compiled (see GRPC_ROUTING_FILES below). + # The matching -D is attached to cuopt_grpc_server alone, the one target + # whose sources test the macro, rather than to every target in the tree. if (NOT SKIP_ROUTING_BUILD) set(CUOPT_ENABLE_GRPC_ROUTING ON) - add_compile_definitions(CUOPT_ENABLE_GRPC_ROUTING) message(STATUS "gRPC routing (VRP) support enabled") else () message(STATUS "gRPC routing (VRP) support disabled (SKIP_ROUTING_BUILD)") @@ -1115,6 +1118,12 @@ if (NOT SKIP_GRPC_BUILD) POSITION_INDEPENDENT_CODE ON ) + # grpc_worker.cpp is the only source that tests this macro, so scope it to + # this target instead of leaking a -D into every target in the directory. + if (CUOPT_ENABLE_GRPC_ROUTING) + target_compile_definitions(cuopt_grpc_server PRIVATE CUOPT_ENABLE_GRPC_ROUTING) + endif () + target_compile_options(cuopt_grpc_server PRIVATE "$<$:${CUOPT_CXX_FLAGS}>" ) diff --git a/cpp/cmake/thirdparty/get_nlohmann_json.cmake b/cpp/cmake/thirdparty/get_nlohmann_json.cmake new file mode 100644 index 0000000000..8981bdb691 --- /dev/null +++ b/cpp/cmake/thirdparty/get_nlohmann_json.cmake @@ -0,0 +1,35 @@ +# cmake-format: off +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# cmake-format: on + +# Header-only JSON parser, used by the routing gRPC test driver to read problem +# files. Uses rapids_cpm_find so an existing install that ships a CMake config +# package (conda, system) is reused, and the pinned source is fetched via CPM +# otherwise. Going through CPM also populates the CPM cache, so any later use of +# nlohmann_json in the project resolves without repeating the search/download. +# +# NOTE: nlohmann_json is not part of rapids-cmake's version file, so the version +# is pinned here rather than by rapids_cpm_. The pin matches cuVS +# (rapidsai/cuvs cpp/cmake/thirdparty/get_nlohmann_json.cmake) and the +# nlohmann_json conda package, so the conda copy is reused rather than fetched. +# +# Test-only: not added to cuopt's BUILD/INSTALL export sets, since nothing that +# is installed links it. +function(find_and_configure_nlohmann_json) + set(oneValueArgs VERSION PINNED_TAG) + cmake_parse_arguments(PKG "" "${oneValueArgs}" "" ${ARGN}) + + rapids_cpm_find(nlohmann_json ${PKG_VERSION} + GLOBAL_TARGETS nlohmann_json::nlohmann_json + CPM_ARGS + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG ${PKG_PINNED_TAG} + EXCLUDE_FROM_ALL + OPTIONS + "JSON_BuildTests OFF" + "JSON_Install OFF" + ) +endfunction() + +find_and_configure_nlohmann_json(VERSION 3.12.0 PINNED_TAG v3.12.0) diff --git a/cpp/tests/routing/grpc/CMakeLists.txt b/cpp/tests/routing/grpc/CMakeLists.txt index 84757ae28d..e7b3613675 100644 --- a/cpp/tests/routing/grpc/CMakeLists.txt +++ b/cpp/tests/routing/grpc/CMakeLists.txt @@ -5,15 +5,7 @@ # Standalone VRP gRPC test driver (not a gtest). Requires a running cuopt_grpc_server. -find_package(nlohmann_json QUIET) -if(NOT nlohmann_json_FOUND) - # Fall back to system header if the CMake package is unavailable. - find_path(NLOHMANN_JSON_INCLUDE_DIR nlohmann/json.hpp) - if(NOT NLOHMANN_JSON_INCLUDE_DIR) - message(WARNING "nlohmann_json not found; skipping GRPC_VRP_TEST_DRIVER") - return() - endif() -endif() +include(${CUOPT_SOURCE_DIR}/cmake/thirdparty/get_nlohmann_json.cmake) add_executable(GRPC_VRP_TEST_DRIVER ${CMAKE_CURRENT_SOURCE_DIR}/grpc_vrp_test_driver.cpp @@ -27,21 +19,14 @@ target_include_directories(GRPC_VRP_TEST_DRIVER "${CMAKE_BINARY_DIR}" ) -if(NLOHMANN_JSON_INCLUDE_DIR) - target_include_directories(GRPC_VRP_TEST_DRIVER PRIVATE "${NLOHMANN_JSON_INCLUDE_DIR}") -endif() - target_link_libraries(GRPC_VRP_TEST_DRIVER PRIVATE cuopt gRPC::grpc++ protobuf::libprotobuf + nlohmann_json::nlohmann_json ) -if(TARGET nlohmann_json::nlohmann_json) - target_link_libraries(GRPC_VRP_TEST_DRIVER PRIVATE nlohmann_json::nlohmann_json) -endif() - if(NOT DEFINED INSTALL_TARGET OR "${INSTALL_TARGET}" STREQUAL "") target_link_options(GRPC_VRP_TEST_DRIVER PRIVATE -Wl,--enable-new-dtags) endif()