Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/cuopt/source/cuopt-python/routing/routing-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ cuOpt Routing Python API Reference
.. autoclass:: cuopt.routing.DataModel
:members:
:undoc-members:
:show-inheritance:

.. autoclass:: cuopt.routing.SolverSettings
:members:
Expand Down
238 changes: 238 additions & 0 deletions python/cuopt/cuopt/routing/_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Deferred-build layer for the routing DataModel: record setter calls, build on demand.

The public DataModel **records** each setter call on the host instead of applying
it to the GPU immediately. The class does not build anything itself -- it stores
the problem and defers construction of the device (Cython) model until it is
actually needed (at solve, or when a getter is queried), at which point it builds
transiently by replaying the recorded calls onto the wrapper. "Deferred" refers
to that deferred build; the mechanism is call recording. Design points:

* **Host-resident IR.** Host inputs (numpy/pandas) are copied to a numpy array
the DataModel owns at record time, so the recorded calls form a serializable,
host-resident intermediate representation (what remote/gRPC serialization
needs) and the user's original array can be released. **Device inputs
(cuDF/cupy) are kept on the device** to preserve the zero-copy GPU path;
they are exported to host only when explicitly serialized.
* **Transient build.** The device model is never cached: each solve/getter
builds it fresh and discards it, so the instance does not hold both a host
IR and a device copy.
* **Zero-CUDA construction.** The setter/getter surface is declared explicitly
below rather than introspected from the compiled wrapper, so constructing and
serializing a problem imports no CUDA/cuDF -- the wrapper is imported lazily
only when a device build actually happens. ``test_deferred`` fails if this
declared surface drifts from the wrapper.

Adding a new setter/getter to the DataModel:

1. Implement it on the C++ ``data_model_view_t`` and the Cython wrapper
(``vehicle_routing_wrapper.pyx``).
2. Add the public method in ``vehicle_routing.py`` -- validation and
docstring -- forwarding to ``super()``.
3. Add its name to ``_SETTERS`` (a mutator) or ``_GETTERS`` (a query) below.

``test_deferred`` cross-checks these names against the wrapper's surface, so a
missing entry fails CI rather than silently dropping the call's data.
"""

import threading

import numpy as np

# Mutating setters: recorded and replayed onto the device model at build time.
_SETTERS = (
"add_break_dimension",
"add_capacity_dimension",
"add_cost_matrix",
"add_initial_solutions",
"add_order_precedence",
"add_order_vehicle_match",
"add_transit_time_matrix",
"add_vehicle_break",
"add_vehicle_order_match",
"set_break_locations",
"set_drop_return_trips",
"set_min_vehicles",
"set_objective_function",
"set_order_locations",
"set_order_prizes",
"set_order_service_times",
"set_order_time_windows",
"set_pickup_delivery_pairs",
"set_skip_first_trips",
"set_vehicle_fixed_costs",
"set_vehicle_locations",
"set_vehicle_max_costs",
"set_vehicle_max_times",
"set_vehicle_time_windows",
"set_vehicle_types",
)

# Non-scalar getters. Currently resolved via a transient build (see
# _make_getter); host-mirror resolution from the IR is migrated incrementally.
_GETTERS = (
"get_break_dimensions",
"get_break_locations",
"get_capacity_dimensions",
"get_cost_matrix",
"get_drop_return_trips",
"get_initial_solutions",
"get_min_vehicles",
"get_non_uniform_breaks",
"get_objective_function",
"get_order_locations",
"get_order_prizes",
"get_order_service_times",
"get_order_time_windows",
"get_order_vehicle_match",
"get_pickup_delivery_pairs",
"get_skip_first_trips",
"get_transit_time_matrices",
"get_transit_time_matrix",
"get_vehicle_fixed_costs",
"get_vehicle_locations",
"get_vehicle_max_costs",
"get_vehicle_max_times",
"get_vehicle_order_match",
"get_vehicle_time_windows",
"get_vehicle_types",
)

# ``get_*`` methods on the wrapper that are helpers, not problem-data getters,
# so they are neither installed nor required by the coverage test.
_SKIP_GETTERS = frozenset({"get_type_from_str", "get_type_from_int"})

_install_lock = threading.Lock()
_methods_installed = False
_BUILT_CLS = None


def _namespace_of(x):
"""Container kind of ``x`` without importing cudf/pandas: one of
{"numpy", "pandas", "cudf", "cupy"} or None (scalar / str / other).
"""
if isinstance(x, np.ndarray):
return "numpy"
return {"pandas": "pandas", "cudf": "cudf", "cupy": "cupy"}.get(
type(x).__module__.split(".", 1)[0]
)


def _normalize(x):
"""Record-time normalization (mixed IR).

Host numeric arrays are copied to a numpy array the DataModel owns (so the
user's array can be released and the IR is serializable). Device (cuDF/cupy)
inputs are kept as-is to preserve the zero-copy GPU path. Scalars, strings,
and non-numeric arrays pass through unchanged.
"""
ns = _namespace_of(x)
if ns in ("cudf", "cupy"):
return x
if ns == "numpy":
return np.array(x, copy=True) if x.dtype.kind in "biuf" else x
if ns == "pandas":
arr = x.to_numpy()
return np.array(arr, copy=True) if arr.dtype.kind in "biuf" else x
return x


class _DeferredDataModel:
"""Records DataModel setter calls; builds the device model transiently."""

def __init__(self, num_locations, fleet_size, n_orders=-1):
_install_methods()
self._init_args = (num_locations, fleet_size, n_orders)
self._calls = []

# -- size scalars: answered without a build (queried during validation) --
def get_num_locations(self):
return self._init_args[0]

def get_fleet_size(self):
return self._init_args[1]

def get_num_orders(self):
# Mirrors the wrapper default: n_orders == -1 means "same as
# num_locations".
n_orders = self._init_args[2]
return self._init_args[0] if n_orders == -1 else n_orders

# -- record --
def _record(self, name, args, kwargs):
args = tuple(_normalize(a) for a in args)
kwargs = {k: _normalize(v) for k, v in kwargs.items()}
self._calls.append((name, args, kwargs))

def _recorded(self, name):
"""Positional args of each prior recorded call to ``name`` (for
set-time "already set?" checks; reads the IR, no shadow state).
"""
return [args for call, args, _ in self._calls if call == name]

# -- transient build (never cached; see module docstring) --
def _build(self):
"""Build a fresh device (Cython) data model by replaying the calls."""
model = _built_cls()(*self._init_args)
for name, args, kwargs in self._calls:
getattr(model, name)(*args, **kwargs)
return model


def _make_setter(name):
def _setter(self, *args, **kwargs):
self._record(name, args, kwargs)

_setter.__name__ = name
return _setter


def _make_getter(name):
def _getter(self, *args, **kwargs):
return getattr(self._build(), name)(*args, **kwargs)

_getter.__name__ = name
return _getter


def _install_methods():
"""Install recorders/getters from the declared surface (not the wrapper).

Done once, lazily, so importing this module needs no CUDA. Thread-safe on
first concurrent construction.
"""
global _methods_installed
if _methods_installed:
return
with _install_lock:
if _methods_installed:
return
for name in _SETTERS:
if name not in _DeferredDataModel.__dict__:
setattr(_DeferredDataModel, name, _make_setter(name))
for name in _GETTERS:
if name not in _DeferredDataModel.__dict__:
setattr(_DeferredDataModel, name, _make_getter(name))
_methods_installed = True


def _built_cls():
"""Return a Python subclass of the Cython wrapper DataModel.

The wrapper is a ``cdef class`` with no ``__dict__``; its ``__init__`` stores
Python attributes, so it must be subclassed by a Python class to be
instantiable. Imported lazily (this is the only CUDA/cuDF entry point).
"""
global _BUILT_CLS
if _BUILT_CLS is None:
with _install_lock:
if _BUILT_CLS is None:
from . import vehicle_routing_wrapper as _wrapper

class _BuiltDataModel(_wrapper.DataModel):
pass

_BUILT_CLS = _BuiltDataModel
return _BUILT_CLS
24 changes: 19 additions & 5 deletions python/cuopt/cuopt/routing/vehicle_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from cuopt import routing
from cuopt.routing import vehicle_routing_wrapper
from cuopt.routing._deferred import _DeferredDataModel
from cuopt.utilities import catch_cuopt_exception

from .validation import (
Expand All @@ -19,7 +20,7 @@
)


class DataModel(vehicle_routing_wrapper.DataModel):
class DataModel(_DeferredDataModel):
"""

DataModel(n_locations, n_fleet, n_orders: int = -1)
Expand Down Expand Up @@ -50,6 +51,12 @@ class DataModel(vehicle_routing_wrapper.DataModel):
host (numpy/pandas) inputs are copied to the device for the local
solve. Python lists and tuples are not supported.

- Inputs are recorded and the device model is built on demand (deferred
to ``Solve``), so solver-side (C++) validation and dtype-cast warnings
surface when the model is built rather than at the individual setter
call. Structural checks (matrix shape, array sizes, value ranges) are
still validated eagerly at the setter.

Examples
--------
>>> from cuopt import routing
Expand Down Expand Up @@ -134,7 +141,8 @@ def add_cost_matrix(
>>> data_model.add_cost_matrix(cost_mat_car, 2)
"""

if vehicle_type in self.costs:
# a[1] is vehicle_type: the recorded call is (cost_mat, vehicle_type).
if vehicle_type in {a[1] for a in self._recorded("add_cost_matrix")}:
raise ValueError("Vehicle type matrix has already been added")

if not skip_validation:
Expand Down Expand Up @@ -216,7 +224,10 @@ def add_transit_time_matrix(self, mat, vehicle_type=0):
>>> time_mat = cudf.DataFrame(time_mat)
>>> data_model.add_transit_time_matrix(time_mat, 0)
"""
if vehicle_type in self.transit_times:
# a[1] is vehicle_type (see add_cost_matrix).
if vehicle_type in {
a[1] for a in self._recorded("add_transit_time_matrix")
}:
raise ValueError("Vehicle type matrix has already been added")

validate_matrix(mat, "transit time matrix", self.get_num_locations())
Expand Down Expand Up @@ -1546,7 +1557,9 @@ def Solve(data_model, solver_settings=None):
if solver_settings is None:
solver_settings = SolverSettings()

solution = vehicle_routing_wrapper.Solve(data_model, solver_settings)
solution = vehicle_routing_wrapper.Solve(
data_model._build(), solver_settings
)
if solver_settings.get_config_file_name() is not None:
routing.utils.save_data_model_to_yaml(
data_model,
Expand Down Expand Up @@ -1603,4 +1616,5 @@ def BatchSolve(data_model_list, solver_settings=None):
if solver_settings is None:
solver_settings = SolverSettings()

return vehicle_routing_wrapper.BatchSolve(data_model_list, solver_settings)
built_list = [dm._build() for dm in data_model_list]
return vehicle_routing_wrapper.BatchSolve(built_list, solver_settings)
41 changes: 41 additions & 0 deletions python/cuopt/cuopt/tests/routing/test_deferred.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import pytest

from cuopt import routing
from cuopt.routing import vehicle_routing_wrapper
from cuopt.routing._deferred import _SKIP_GETTERS


def test_deferred_covers_wrapper_surface():
"""Every public wrapper DataModel method must be handled by the recording
layer (installed as a recorder/getter or an explicit override). This fails
loudly if a new wrapper method -- e.g. a mutator not named set_*/add_* --
is added without being recorded, instead of silently dropping its data.
"""
dm = routing.DataModel(1, 1) # triggers _install_methods

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if user calls dm.random_func_call(x, y, z) ?
Should this be a test or a validation within cuopt

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. dm.random_func_call(x, y, z) raises AttributeError — the recording layer installs only the declared setter/getter surface and has no __getattr__ catch-all, so an unknown or typo'd call fails loudly and never enters the IR (_calls stays empty). So it's standard Python behavior rather than something cuOpt needs to validate. I've added test_unknown_method_is_not_recorded to lock this behavior.

handled = set(dir(type(dm)))
missing = [
name
for name in dir(vehicle_routing_wrapper.DataModel)
if not name.startswith("_")
and name not in _SKIP_GETTERS
and name not in handled
]
assert not missing, (
f"deferred-build layer does not handle wrapper methods {missing}; "
"add a recorder/getter or list them in _SKIP_GETTERS"
)


def test_unknown_method_is_not_recorded():
"""A call to a method that is not part of the DataModel surface raises
AttributeError rather than being silently recorded. The recording layer
installs only the declared setters/getters (no ``__getattr__`` catch-all),
so a typo'd or unknown call fails loudly and never enters the IR.
"""
dm = routing.DataModel(3, 1)
with pytest.raises(AttributeError):
dm.random_func_call(1, 2, 3)
assert dm._calls == []
Loading
Loading