You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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-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:
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:
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 shared COMPONENT runtime
the conda package_contents assertion, into the owning output
the wrapper module and its [project.scripts] entry — _cli_wrapper.py and _grpc_server_wrapper.py live in python/libcuopt/libcuopt/ today, so this implies new python/libcuopt_mathopt/ and python/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 everyconda-python-tests job on #1622 once libcuopt.so became a linker script and a Cython extension loaded the client directly:
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.txt currently set linked_libraries cuopt::cuopt. Until they point at cuopt::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}.sh scripts (modeled on existing ci/build_wheel_libcuopt.sh)
New python/libcuopt_{routing,mathopt,grpc}/ package directories with pyproject.toml
Per-package dependency sets in dependencies.yaml — this is where cudss/nccl/cusparse get attached to LP only
Update python/cuopt/pyproject.toml optional-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)
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-cu12wheel 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
libcuoptwheel 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 unaffectedcuopt_baseno longer exists. It was removed from #1622 after measurement showed a single consumer:libcuopt_mathopt.soreferenced 9 symbols from it (seed_generator::seed_,print_version_info, sixwork_unit_scheduler_tmethods,printTimestamp), while routing, grpc, client,cuopt_cliandcuopt_grpc_serverreferenced 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_FILESnow builds intocuopt_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:cuopt_routingcuopt_mathoptcuopt_grpccuopt_mathopt,cuopt_routingcuopt_clientTwo things changed since the original measurement.
cuopt_lpis nowcuopt_mathopt, renamed on #1622 after review, since it covers LP/QP/SOCP/MIP rather than LP alone. Andcuopt_clientis 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_baserow is gone: that component was removed from #1622, see below.readelf -dconfirmslibcuopt_routing.sohas noDT_NEEDEDonlibcuopt_mathopt.soand vice versa — the two engines are independent, so no C++ untangling is required.libcuopt_grpc.sois the exception: it links both engines (proto mappers for LP and routing). As specified above,libcuopt-grpc-cu12therefore 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.
cuopt_clicuopt_grpc_servercuopt_clibelongs 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.cppcallssolve_lp_remote/solve_mip_remotedirectly, and both live incuopt_client, so the remote path resolves at link time. The CLI references the registry not at all and carries noDT_NEEDEDonlibcuopt_grpc.so.Worth stating plainly because it is easy to get backwards: remote execution is a
cuopt_clientcapability, not acuopt_grpcone. Any package withcuopt_clientcan do remote solves.cuopt_grpcis only needed for the in-library dispatch path, wheresolve_lp/solve_mipreach the remote solvers through nullable callbacks thatlibcuopt_grpc.so's constructor fills, afterensure_remote_solvers_loaded()dlopens it.cuopt_grpc_servershould 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 thelibcuoptmetapackage 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 behindCUOPT_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 underlibcuopt/bin/and are surfaced through Python wrappers:Splitting the binaries therefore moves three things per binary, not one:
install(TARGETS ...)rule, which needs a per-package install component rather than today's sharedCOMPONENT runtimepackage_contentsassertion, into the owning output[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 rulesDo not collapse the wrappers into direct
subprocesscalls while moving them._grpc_server_wrapperusesos.execvdeliberately: 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.soreferenced two rmm symbols with noDT_NEEDEDonlibrmm. It resolved by accident whilelibcuopt.sowas a real ELF that linked rmm, then broke everyconda-python-testsjob on #1622 oncelibcuopt.sobecame a linker script and a Cython extension loaded the client directly: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_NEEDEDclosure provides; fail on any remainder. Two details matter: skip weak undefined symbols, which are allowed to stay unresolved, and strip@VERSIONsuffixes soabort@GLIBC_2.2.5compares equal to libc'sabort. This is a different question fromci/check_symbols.sh, which asserts internal symbols are not exported; this asserts needed symbols are resolvable.2. A runtime isolation test.
dlopeneach component withRTLD_NOW | RTLD_LOCALand require success.RTLD_NOWis 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 everydlopenwould 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 andNEEDEDentries, so a library with undefinedrmm::symbols looked clean. Any guard should coverrmm::,raft::andcuda*too. See #1890.Prerequisites
PR #1622 (
feat/split-routing-lp-libs) splits the monolithiclibcuopt.sointo component.sofiles (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.soboundary work in #1622 makes this cleaner than the prior attempt.Work involved
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.install(TARGETS ${CUOPT_COMPONENT_TARGETS} ... COMPONENT runtime)currently stages them together.install(DIRECTORY include/cuopt/)is monolithic today, so dev headers need to follow their component.ci/build_wheel_libcuopt_{routing,mathopt,grpc}.shscripts (modeled on existingci/build_wheel_libcuopt.sh)python/libcuopt_{routing,mathopt,grpc}/package directories withpyproject.tomldependencies.yaml— this is where cudss/nccl/cusparse get attached to LP onlypython/cuopt/pyproject.tomloptional-dependency groups to reference the split packagesTarget
26.10 (too late for 26.08)