Repository navigation
Description
Background
Publishing cuOpt to pypi.org (instead of only pypi.nvidia.com) would simplify installation, remove the need for --extra-index-url, and unblock security-conscious users who restrict installs to the main PyPI index.
The blocker is package size. The current libcuopt-cu12 wheel is ~542 MB compressed. PyPI enforces a 1 GB per-package limit and a soft 100 GiB per-project limit; headroom exists today but is not comfortable enough to commit to indefinitely, especially with features like multi-GPU PDLP added recently.
What this issue tracks
Split the single libcuopt wheel into separate wheels along solver boundaries:
libcuopt-routing-cu12— routing/VRP engine (libcuopt_routing.so)libcuopt-mathopt-cu12— LP/MILP/QP/SOCP solver (libcuopt_mathopt.so)libcuopt-grpc-cu12— gRPC client (libcuopt_grpc.so); depends on both engines, see belowlibcuopt-client-cu12— CUDA-free host-side client (libcuopt_client.so), see Package libcuopt_client separately so the gRPC client installs without CUDA #1872libcuopt-cu12— metapackage depending on all of the above, so existing installs are unaffected
cuopt_base no longer exists. It was removed from #1622 after measurement showed a single consumer: libcuopt_mathopt.so referenced 9 symbols from it (seed_generator::seed_, print_version_info, six work_unit_scheduler_t methods, printTimestamp), while routing, grpc, client, cuopt_cli and cuopt_grpc_server referenced none. Its original rationale — shared stateful infrastructure — had eroded: the logger became header-only per component in #1778, linear algebra moved into mathopt after review, and #1809 replaced the process-wide seed generator with per-worker RNGs. UTIL_SRC_FILES now builds into cuopt_mathopt.
That removes a package from this split and the question of whether the routing wheel should depend on base.
The corresponding Python meta-packages (cuopt-vrp, cuopt-lp, etc.) would declare only the lib package they need, so users who only use routing don't pull in the LP solver and vice versa.
Component boundaries (measured on #1622, stripped)
Re-measured 2026-09-09 on #1622 merged with main, stripped with strip --strip-unneeded:
| Component | Size | External deps beyond rmm / rapids_logger |
|---|---|---|
cuopt_routing |
25.5 MB | cublas |
cuopt_mathopt |
44.6 MB | cublas, cusparse, nccl, cudss, TBB |
cuopt_grpc |
1.1 MB | grpc, protobuf, abseil, cuopt_mathopt, cuopt_routing |
cuopt_client |
2.3 MB | grpc, protobuf, abseil — no CUDA |
Two things changed since the original measurement. cuopt_lp is now cuopt_mathopt, renamed on #1622 after review, since it covers LP/QP/SOCP/MIP rather than LP alone. And cuopt_client is a fifth component, added by #1804 — it is the only one with no CUDA dependency at all, which is what makes a GPU-free client install possible. Its packaging is tracked separately in #1872 and should be done as part of this work rather than in isolation.
The cuopt_base row is gone: that component was removed from #1622, see below.
readelf -d confirms libcuopt_routing.so has no DT_NEEDED on libcuopt_mathopt.so and vice versa — the two engines are independent, so no C++ untangling is required.
libcuopt_grpc.so is the exception: it links both engines (proto mappers for LP and routing). As specified above, libcuopt-grpc-cu12 therefore pulls in everything. Either accept that, or split its LP and routing mappers into separate translation units so a routing-only gRPC client is possible. This is the only part of the split with real C++ work behind it.
Secondary benefit, distinct from the PyPI size limit: cusparse, nccl, and cudss are math-opt-only and ship as separate nvidia-* wheels — ~1.2 GB of uncompressed on-disk libraries in a local build environment. A routing-only install currently pulls all of them and never calls into them. This does not affect the per-package limit that motivates this issue, but it is a large win for end-user install footprint.
Where the two binaries go
Measured on #1622 merged with main. Symbol counts are what each binary actually resolves from each component.
| Binary | client | mathopt | routing | grpc |
|---|---|---|---|---|
cuopt_cli |
46 | 12 | 0 | 0 |
cuopt_grpc_server |
134 | 27 | 4 | 0 |
cuopt_cli belongs in the math-opt package. It has no routing symbols and no mention of routing or VRP in its source — it is MPS-driven, so LP/MIP/QP only. Shipping it there means a routing-only install does not pull the LP/QP engine to get a binary it cannot use.
That holds even though the CLI supports remote execution. cuopt_cli.cpp calls solve_lp_remote / solve_mip_remote directly, and both live in cuopt_client, so the remote path resolves at link time. The CLI references the registry not at all and carries no DT_NEEDED on libcuopt_grpc.so.
Worth stating plainly because it is easy to get backwards: remote execution is a cuopt_client capability, not a cuopt_grpc one. Any package with cuopt_client can do remote solves. cuopt_grpc is only needed for the in-library dispatch path, where solve_lp / solve_mip reach the remote solvers through nullable callbacks that libcuopt_grpc.so's constructor fills, after ensure_remote_solvers_loaded() dlopens it.
cuopt_grpc_server should be its own package, cuopt-grpc-server. It is the one artifact that needs every engine, and it is a separately-run service with its own lifecycle, so it versions and installs independently of the libraries. Putting it in the libcuopt metapackage would make that package carry files rather than stay a pure dependency alias, and would force every server user to install both engines.
Note its routing dependency is thin — 4 symbols: routing::solve, cpu_routing_problem_t::to_device, host_assignment_t's constructor, and a deleter. It already compiles behind CUOPT_ENABLE_GRPC_ROUTING, scoped to that target alone, so a math-opt-only server build is already supported if the footprint ever justifies splitting it.
The console-script plumbing moves with each binary
Easy to discover late, so worth stating. A wheel cannot ship a bare executable on $PATH, so both binaries live under libcuopt/bin/ and are surfaced through Python wrappers:
[project.scripts]
cuopt_cli = "libcuopt._cli_wrapper:main"
cuopt_grpc_server = "libcuopt._grpc_server_wrapper:main"Splitting the binaries therefore moves three things per binary, not one:
- the
install(TARGETS ...)rule, which needs a per-package install component rather than today's sharedCOMPONENT runtime - the conda
package_contentsassertion, into the owning output - the wrapper module and its
[project.scripts]entry —_cli_wrapper.pyand_grpc_server_wrapper.pylive inpython/libcuopt/libcuopt/today, so this implies newpython/libcuopt_mathopt/andpython/cuopt_grpc_server/package directories, not just new CMake rules
Do not collapse the wrappers into direct subprocess calls while moving them. _grpc_server_wrapper uses os.execv deliberately: spawning a child leaves a Python parent that forwards no signals, so a signal to the console script's pid kills only the wrapper while the server and its GPU workers survive, orphaned and still holding the listen port, with the shutdown path that cancels jobs and reaps workers never running.
Guard the split: test each package in isolation
While every component ships in one package they are always loaded together, so a library can reference a symbol it never links and still work, because something else happened to pull the provider in. Splitting the packages removes that safety net, and nothing in CI currently checks for it.
This is not hypothetical. libcuopt_client.so referenced two rmm symbols with no DT_NEEDED on librmm. It resolved by accident while libcuopt.so was a real ELF that linked rmm, then broke every conda-python-tests job on #1622 once libcuopt.so became a linker script and a Cython extension loaded the client directly:
ImportError: libcuopt_client.so: undefined symbol:
_ZN3rmm10_RMM_26_1016cuda_stream_viewC1EP11CUstream_st
Two guards were prototyped against #1622 and verified to catch exactly that failure when the fix is reverted. Both were held back to land with the packaging work rather than expand #1622:
1. A static check per component. For each library, compare the symbols it needs against those its declared DT_NEEDED closure provides; fail on any remainder. Two details matter: skip weak undefined symbols, which are allowed to stay unresolved, and strip @VERSION suffixes so abort@GLIBC_2.2.5 compares equal to libc's abort. This is a different question from ci/check_symbols.sh, which asserts internal symbols are not exported; this asserts needed symbols are resolvable.
2. A runtime isolation test. dlopen each component with RTLD_NOW | RTLD_LOCAL and require success. RTLD_NOW is the point — it forces every relocation to resolve at load time rather than lazily, so a missing provider fails in the test instead of at first call. The test executable must link no cuOpt library: if it did, the components would already be in the process and every dlopen would trivially succeed, which is precisely the condition that hides the bug.
Both should run per component, skipping any that a given configuration does not build (SKIP_ROUTING_BUILD, SKIP_GRPC_BUILD).
Worth noting why this was missed by hand: the verification used at the time counted undefined cuopt:: symbols and NEEDED entries, so a library with undefined rmm:: symbols looked clean. Any guard should cover rmm::, raft:: and cuda* too. See #1890.
Prerequisites
PR #1622 (feat/split-routing-lp-libs) splits the monolithic libcuopt.so into component .so files (libcuopt_routing, libcuopt_mathopt, libcuopt_grpc). That clean boundary is required before this package split is possible without rearchitecting the C++ build. This issue should not be started until that PR merges.
Prior art
A POC splitting cuOpt into cuopt-common + per-solver packages was explored previously but deferred for other priorities. The .so boundary work in #1622 makes this cleaner than the prior attempt.
Work involved
- Relink the Cython extension modules against their actual components. All four of
python/cuopt/cuopt/{routing,linear_programming/solver,grpc/linear_programming,distance_engine}/CMakeLists.txtcurrently setlinked_libraries cuopt::cuopt. Until they point atcuopt::routing/cuopt::mathopt/cuopt::grpc, every extension pulls the full graph and the optional-dependency groups achieve nothing. Worth doing first as a standalone PR: it is small, it de-risks the packaging work, and a mislinked module fails to link rather than silently over-depending. - Give each component its own install component;
install(TARGETS ${CUOPT_COMPONENT_TARGETS} ... COMPONENT runtime)currently stages them together. - Split header installation —
install(DIRECTORY include/cuopt/)is monolithic today, so dev headers need to follow their component. - New
ci/build_wheel_libcuopt_{routing,mathopt,grpc}.shscripts (modeled on existingci/build_wheel_libcuopt.sh) - New
python/libcuopt_{routing,mathopt,grpc}/package directories withpyproject.toml - Per-package dependency sets in
dependencies.yaml— this is where cudss/nccl/cusparse get attached to LP only - Update
python/cuopt/pyproject.tomloptional-dependency groups to reference the split packages - Update conda recipes accordingly
- Verify compressed sizes stay well under 750 MB per package (per RAPIDS guidance)
- Coordinate with RAPIDS build-infra on PyPI org registration (see rapidsai/build-infra#356)
Target
26.10 (too late for 26.08)
Activity
build(cmake): shorten cuopt_mathematical_optimization to cuopt_mathopt
grpc: isolate the routing arm so the component split stays mechanical
ramakrishnap-nv commentedon Aug 14, 2026
Note on step 3 (splitting the gRPC client)
Suggesting we change the target for the gRPC part of this split.
As currently worded, step 3 splits
cuopt_grpc's LP and routing mappers so arouting-only gRPC client is possible. That yields two client libraries that both
need the same transport (connect/TLS/submit/status/poll/chunking), so the split
duplicates infrastructure without removing the dependency that actually matters.
The more useful cut is client vs engine, not routing vs LP:
That gives a client installable on machines with no GPU and no CUDA runtime,
which is the property remote users want. Routing and LP stay as arms inside one
client unit.
What blocks it today:
cpp/src/routing/cpu_routing_problem.cuis a CUDAtranslation unit —
device_data_tandto_device()are declared on theclient-side host type, so it pulls RMM. The header itself is device-free (all
members are
std::vector); it is the.cuthat binds it. LP has no equivalentproblem:
cpu_optimization_problem.cppis already plain C++.So the prerequisite is separating
cpu_routing_problem_t's host struct from itsto_device(), leaving the latter in the routing engine.Worth noting that "works in a container without
--gpus" (as validated in#1597) is a weaker property than "does not depend on CUDA" — the library still
links the full stack today; it just never touches a device.
On the Python side,
cuopt.grpc.linear_programming.Clientandcuopt.grpc.routing.RoutingClientare two extension modules that wrap the sameC++ class (
grpc_python_client_tis declared in both.pxdfiles). Combiningthem into one extension — keeping both classes and both import paths — removes
that duplication and means the future client package is one unit, not two.
python: build the gRPC clients as one extension module
refactor: build the routing gRPC arm into cuopt_client (#1884)
build(cmake): fold cuopt_grpc into cuopt_mathopt and fix routing's link
build(cython): link each extension module against the component it uses
build(cython): link each extension module against the component it uses
build(cython): link each extension module against the component it us…
build(cmake): give each component its own install component
build(cmake): give each component its own install component (#1926)
ramakrishnap-nv commentedon Sep 29, 2026
Status as of 2026-09-29
The C++ split is done and on
main:libcuopt-clientTwo corrections to the plan in the description above, so nobody works from it as written:
libcuopt-grpcpackage, and there will not be one. There is no separatecuopt_grpc.so; the gRPC code is compiled into client, mathopt and routing. The "only part of the split with real C++ work" note about splitting its LP and routing mappers no longer applies.cuopt-vrp,cuopt-lp) in the description are untouched, and the Pythoncuoptwheel is still one package spanning both engines.pip install libcuoptis unchanged for users, and a routing-only install no longer pulls cudss, nccl or nvjitlink.Remaining before this issue can close
The stated goal here is publishing to pypi.org; the split was only the blocker. Nothing has been published there yet, so that step is still open.
Follow-ups found during the work
Functional:
cuopt_baseremoval. The split is now onmain, so this is a live defect rather than a branch artifact. The most substantive item on this list.ci/check_symbols.shenforces the invariant today (logger state must stay hidden, or the per-component loggers collapse into one), but the tracking issue is still open.Packaging hygiene, none currently tracked:
cuopt_cliunable to resolvelibcuopt_client.sofrom a standalonelibcuopt-mathopt, andlibcudss_mtlayer_cuopt.soreaching no package at all. A smoke test that installs each wheel in a clean environment,ctypes.CDLLs each library and runscuopt_cli --helpwould have caught both. Cheapest high-value item here.libcuopt-mathoptandlibcuopt-routinggetrapids-loggeronly transitively throughlibrmm, thoughlibrapids_logger.sois in both theirDT_NEEDED. This is exactly the break that surfaced on the client in build(wheel): drop the CUDA suffix and dimension from libcuopt-client #1967: removinglibrmmtookrapids-loggerwith it and the conda isolation test failed onlibrapids_logger.so: cannot open shared object file. Both should declare it.cuda12_...,cuda13_...). Harmless but wasteful; the wheel is deduplicated and conda is not, because the recipe is driven once per CUDA variant, so identical strings would collide on upload.find_package(cuopt)succeeding early yields a silently empty wheel. Each component wheel's CMake returns early oncuopt_FOUND, so a build against an installed cuOpt produces a wheel with no library and no error.-DCMAKE_DISABLE_FIND_PACKAGE_cuopt=ONis the workaround.matrix-entryresolveslibrmmunsuffixed for the client's build requires. Inert in CI, which builds withPIP_NO_BUILD_ISOLATION=0and pre-installs requirements from an explicit matrix, but an isolated build would fail to resolve it. Inherent todisable-cuda, which drops thecudadimension so no suffixed matrix entry can match.Separating the Python client
Worth doing, and the coupling is thinner than it looks. The Cython gRPC client already links only
cuopt::client(plusrmm::rmm, for the exception handlers Cython emits), andgrpc/client/CMakeLists.txtwas written with this in mind: both arms share one extension module so the client can be detached as a single package later.What blocks it is
grpc_client.pyxcimportingDataModelandSolverSettings, whose extensions linkcuopt::mathopt. Measured on a local build -- worth re-measuring on currentmainbefore acting -- that link is three symbols:data_model_wrapperbarrier_cache_t::update_linear_objectivesolver_settingssolver_settings_t<int,double>::solver_settings_t(),::set_pdlp_warm_start_dataparser_wrapperandinternalsalready linkcuopt::clientalone.So a CUDA-free
cuopt-clientPython package needs those three moved intocuopt_client, not a restructuring. Arguably they belong there anyway:solver_settings_tis problem description, which is the client's half of the boundary this split already draws. Same shape as thecuopt_baseremoval recorded above, which was justified on nine symbols.The payoff is the Python-level version of what #1967 achieved for C++:
pip install cuopt-clientwith no CUDA wheels at all. Today the only CUDA-free Python client iscuopt-sh-client, which speaks HTTP to a self-hosted service rather than gRPC.ramakrishnap-nv commentedon Sep 29, 2026
Filed #2005 for the per-solver Python packages (
cuopt-vrp/cuopt-lp), the half of this issue's original plan that the C++ split left untouched.The tree is already close to it: no cross-imports between
cuopt/routingandcuopt/linear_programming, each directory links only its own engine,__init__.pyalready resolves submodules lazily to support partial installs, and the tests already sit on the boundary. The open question is makingcuopta namespace package and finding a home forcuopt/utilities.