From 2e9de8d3c282b61810c1518afe45a0f2cefcdea1 Mon Sep 17 00:00:00 2001 From: Trevor McKay Date: Fri, 10 Jul 2026 09:34:25 -0400 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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.