NVIDIA / NVIDIA/cuopt

Split libcuopt wheel into separate routing/LP packages for PyPI publication

Open
#1,635 1 comment 0 reactions 2 assignees View on GitHub

@ramakrishnap-nv is already working on this.

Since Aug 25, 2026.

awaiting response
Dominant language
Cuda
Stars
1k
Forks
233
Avg merge
4d 4h
Merged PRs (30d)
95

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 below
  • libcuopt-client-cu12 — CUDA-free host-side client (libcuopt_client.so), see #1872
  • 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:

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:

  1. the install(TARGETS ...) rule, which needs a per-package install component rather than today's shared COMPONENT runtime
  2. the conda package_contents assertion, into the owning output
  3. 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 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.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)

Target

26.10 (too late for 26.08)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.