Skip to content

Explore self-contained Java classifier JARs with a statically linked libcuopt - #1818

Draft
ramakrishnap-nv wants to merge 16 commits into
mainfrom
java-static-classifiers
Draft

Explore self-contained Java classifier JARs with a statically linked libcuopt#1818
ramakrishnap-nv wants to merge 16 commits into
mainfrom
java-static-classifiers

Conversation

@ramakrishnap-nv

@ramakrishnap-nv ramakrishnap-nv commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Description

Draft, opening the work for #1817. It now produces a working self-contained classifier JAR: a solve runs from the JAR alone, with no libcuopt, no conda environment and no -Dcuopt.native.dir.

=== jar + CUDA math libs only ===
status    = OPTIMAL
objective = 5.0
take_0    = 1.0

Result

cuopt-26.10.0-cuda13.jar, 405 MB, built for CUDA arch 75 with routing and gRPC excluded:

library size why it is there
libnccl.so.2 278.7 MB distributed PDLP, unreachable from Java
libcuopt_jni.so 172.6 MB cuOpt linked in statically
libcudss.so.0 66.7 MB direct solver
librmm.so, libtbb.so.12, librapids_logger.so 2.2 MB exception types and KaMinPar's TBB use

Against the 1 GB bundle limit: one classifier fits comfortably; four classifiers as a single bundle would be ~1.6 GB. Whether classifiers upload separately is open on build-infra#379.

Note this is a single CUDA architecture. A real multi-arch fatbin grows libcuopt_jni.so, though not the bundled .so files.

What static linking actually removed, and what it did not

-DBUILD_SHARED_LIBS=OFF does not work here: cuopt is declared add_library(cuopt SHARED ...), so the flag is ignored. cuopt_static already existed but only inside if (BUILD_TESTS); it is now gated on BUILD_TESTS OR CUOPT_BUILD_STATIC_LIB.

Linking that archive removed the dependency on libcuopt.so, but not on the libraries cuOpt itself needs and conda ships only as shared objects. Each appeared as a separate UnsatisfiedLinkError, one per rebuild:

  1. _ZTIN3rmm10_RMM_26_109bad_allocE — rmm's exception typeinfo
  2. tbb::detail::r1::throw_exception — TBB, via KaMinPar
  3. ncclGroupStart — NCCL
  4. cudssDestroy — cuDSS

They are linked and packaged beside the JNI library, which resolves them through its existing $ORIGIN RPATH.

NCCL

NCCL is 69% of the JAR and cannot be reached from Java: pdhg.hpp holds multi_gpu_engine_t* as a forward-declared pointer defaulting to nullptr, and the whole surface is 14 symbols across 5 files under cpp/src/pdlp/distributed_pdlp/.

Findings from investigating whether it could be linked statically instead:

  • conda's nccl ships no static archive — only libnccl.so{,.2,.2.30.7}, libnccl_device.bc and a pkgconfig file. libnccl_static.a exists only in apt's libnccl-dev, which would mean sourcing one dependency from apt while the rest comes from conda.
  • static is not smaller anyway: in nvidia/cuda:13.0.3-devel, libnccl_static.a is 190 MB against 181 MB for the shared library. NCCL's bulk is per-architecture device code, which comes along either way.
  • NCCL is not part of the CUDA toolkit. nvidia/cuda:13.0.3-base has none; the devel image gets it from the libnccl2 apt package at /lib/x86_64-linux-gnu, not /usr/local/cuda/lib64. So it cannot be treated as consumer-supplied the way cuBLAS can.
  • It is a hard dependency in dependencies.yaml for both paths — nccl >=2.19 under build_cpp, nvidia-nccl-cu1{2,3}>=2.19 under cuda_wheels. There is no build in which cuOpt does without it.

So it is bundled as a shared library for now, per the decision to keep it a dependency and revisit later. The size win is not in how it is linked but in not needing it: a switch to compile out distributed PDLP would take the JAR from 405 MB to roughly 126 MB, and loading NCCL lazily through dlopen would do the same for conda and wheel users, who currently carry ~280 MB for a feature most never use. cpp/CMakeLists.txt has no such switch today, unlike SKIP_ROUTING_BUILD and SKIP_GRPC_BUILD.

CI

java-static-build runs ci/build_java_static.sh end to end — static libcuopt, static link, package, verify — in build.yaml and pr.yaml, gated on the same file groups as java-build and part of the pr-builder aggregator.

verify_jar_dependencies.sh is the check worth having. It reads DT_NEEDED for every packaged library and allows only what is inside the JAR, provided by the CUDA toolkit, or part of the base system.

Reading DT_NEEDED rather than resolving against a library directory is the point. The first version pointed ldd at the conda prefix and passed a JAR with libnccl.so.2 deleted, because the prefix contains it either way — the build environment makes any JAR look self-contained. It now fails correctly:

ERROR: the JAR is not self-contained. Unsatisfied dependencies:
  libcuopt_jni.so needs libnccl.so.2

It also fails if libcuopt.so reappears in DT_NEEDED, which would mean the static link silently fell back to shared.

Not regressed

The shared libcuopt path is untouched. CUOPT_STATIC_BUILD_DIR and CUOPT_BUILD_STATIC_LIB are both empty/off by default, -Dcuopt.native.dir is still the loader's first strategy, and ./build.sh java --run-java-tests passes 35/35 producing an unclassified JAR.

Open questions

  1. Should distributed PDLP be compilable out, or NCCL loaded lazily? Biggest size win, and it fixes libcuopt failing to load on a plain CUDA runtime image.
  2. Do classifier JARs upload to Maven Central separately, or as one bundle? Four at this size exceed 1 GB together.
  3. Should the build move into a container, as cuDF does, with a local reproducer alongside?

Checklist

  • I am familiar with the Contributing Guidelines.
  • Testing
    • Self-contained JAR built, verified, and run
    • verify_jar_dependencies.sh tested against a deliberately broken JAR
    • Multi-arch and aarch64 not yet measured
  • Documentation
    • Deferred until the approach is confirmed

@copy-pr-bot

copy-pr-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

cuopt_static existed only inside the BUILD_TESTS block, because the internal
tests were its only consumer. Embedding cuOpt into a single self-contained
shared object needs the same archive, so it is now gated on BUILD_TESTS or a
new CUOPT_BUILD_STATIC_LIB option, with the tests block left to add_subdirectory
alone.

build_static_libcuopt.sh builds that archive scoped to what the Java bindings
expose — no routing, no gRPC — and reports its size. The shared libcuopt is
554 MB against 29 DT_NEEDED entries, and Maven Central caps an upload bundle
at 1 GB, so the measurement decides whether self-contained classifier JARs are
feasible at all.

Contributes to #1817.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
A published JAR is the only thing a consumer installs, so the library has to
come out of it. NativeLibraryLoader keeps -Dcuopt.native.dir first for a
library built from source, then falls back to a copy embedded in the JAR, then
to the library path. The embedded copy is extracted once per user and reused
when the size already matches, since re-extracting hundreds of megabytes on
every JVM start would dominate startup.

build_cuopt_java_jar.sh packages one classifier, placing the library where the
loader looks. It refuses a library that still carries a DT_NEEDED on
libcuopt.so: that loads on the build machine and fails for a consumer who
installed nothing else, which is the whole failure this is meant to remove.

The POM gains a classifier and a native-resource directory, both empty by
default so the source build and the test suite are unchanged.

Contributes to #1817.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
Linking libcuopt_static.a into cuopt_jni removes the dependency on
libcuopt.so, but not on the libraries cuOpt itself needs and conda ships only
as shared objects. Each surfaced as an UnsatisfiedLinkError in turn: rmm's
exception typeinfo, TBB via KaMinPar, NCCL, then cuDSS. They are linked and
packaged beside the JNI library, which finds them through its $ORIGIN RPATH,
and the loader lays them out before loading it.

NCCL is 279 MB of the 405 MB result and is only needed for distributed PDLP,
which a Java JAR cannot reach. cpp/CMakeLists.txt has no switch to compile
that path out; adding one is the single biggest size win available.

The shared libcuopt path is untouched: CUOPT_STATIC_BUILD_DIR is empty by
default, cuopt.native.dir is still tried first, and the source build still
passes 35/35.

Contributes to #1817.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv
ramakrishnap-nv force-pushed the java-static-classifiers branch from dd6ae68 to e521ce7 Compare August 27, 2026 18:27
ci/build_java_static.sh runs the whole path — static libcuopt, static link,
packaging — and then checks the result. java-static-build runs it alongside
java-build, which still covers the shared libcuopt path.

verify_jar_dependencies.sh is the check worth having. Every missing library
found while getting this working (rmm, TBB, NCCL, cuDSS) appeared only as an
UnsatisfiedLinkError at run time, because the build environment supplies them
all and the JAR looks fine there. It reads DT_NEEDED and allows only what is
packaged in the JAR, provided by the CUDA toolkit, or part of the base system.

Reading DT_NEEDED rather than resolving against a library directory matters:
the first version pointed ldd at the conda prefix and passed a JAR with
libnccl.so.2 deleted, since the prefix contains it either way.

Contributes to #1817.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
The job was only in build.yaml, which runs on branch and nightly builds, so it
would never have run on the PR proposing it. pr.yaml now runs it too, gated on
the same test_java and test_cpp file groups as java-build and included in the
pr-builder aggregator so a failure fails the PR.

It runs on cpu16 rather than a GPU node: the job compiles the JAR and inspects
its dependencies, but does not execute it.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test a0816cb

A publishing workflow consumes a Maven repository tree, so the shape is fixed
here rather than left to whatever downloads these JARs.

assemble_maven_repo.sh gathers the classifier JARs, the POM renamed from
pom.xml to cuopt-<version>.pom, and the sources and javadoc JARs that Maven
Central requires, into com/nvidia/cuopt/cuopt/<version>/. It reads the version
from a JAR name so the layout can only describe artifacts that exist, and
refuses a non-empty output directory so a stale tree cannot be published.

java-static-build uploads that tree as cuopt_java_maven_repo.

Requested by @paul-aiyedun for the nightly Sonatype snapshot workflow in
rapidsai/build-infra#379.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 87b17bb

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

CI Test Summary

✅ All 31 test job(s) passed.

java-static-build now runs as a matrix over CUDA major and architecture,
producing cuda12, cuda12-arm64, cuda13 and cuda13-arm64, each uploaded as
cuopt_java_<arch>_cu<major>. java-static-gather downloads them and assembles
one cuopt_java_maven_repo artifact, which is what a publishing workflow
consumes.

Standardized on cuDF's conventions while doing so: argparse.sh gives the
scripts one way to reject a missing or empty flag, the matrix comes from
compute-matrix.yaml filtered to one entry per arch and CUDA major, and each
classifier directory carries its own POM, sources and javadoc JARs so the
gather step can work from those directories alone.

CI measured the first classifier at 599 MB, against 405 MB locally: the
difference is libcuopt_jni.so growing from 173 MB to 371 MB once every CUDA
architecture is built. Four classifiers therefore exceed the 1 GB Maven Central
bundle limit together, though each fits individually.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 6df31e9

Testing the packaged JAR with a bespoke smoke test would have covered a
fraction of what the suite already covers, so java-static-test runs the suite
itself through a packaged-jar-tests profile, following cuDF's
ci/test_packaged_java.sh. Main compilation is skipped so the JAR supplies both
the classes and the native libraries, and the artifact is fetched with
rapids-download-from-github rather than gh, which is what handles the
pull-request and nightly cases.

Two things this found.

NativeTestSupport.assumeNativeLibrary required cuopt.native.dir, which encodes
"a native library means a source build". Run against a classifier JAR the suite
reported 35 found, 14 passed, 21 aborted — silently skipping every native test,
in the configuration where they matter most. It now accepts either route.

PackagedJarOriginCheck asserts the classes and the embedded library really came
from a JAR, because a stray target/classes on the classpath would shadow it and
the run would pass while testing the wrong thing. Confirmed it fails when
cuopt.native.dir is set to bypass the JAR. It matches none of surefire's
default name patterns, so the profile names it explicitly.

Against a classifier JAR: 38/38. From source, unchanged at 35/35, with the
origin check excluded.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test a4219ed

rapids-check-pr-job-dependencies requires every job to be a dependency of
pr-builder. The build, test and gather jobs were listed but the matrix job that
feeds them was not, so the checks job failed. pr-test-summary remains the only
job outside the aggregator, which is expected and already ignored.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test a623cc4

All four java-static-test jobs failed resolving maven-source-plugin from Maven
Central with a 429. cuopt_mvn exists to retry exactly that, but the packaging
and test scripts called mvn directly and so never got it.

The version is now read from the POM's update marker instead of by invoking
Maven. That removes a network round trip from the packaging step, and avoids
capturing the wrapper's merged stderr into the version string.

Signed-off-by: Ramakrishna Prabhu <ramakrishnap@nvidia.com>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 78822d7

ramakrishnap-nv and others added 2 commits August 28, 2026 11:12
cuOpt's C++ logger writes console output directly to std::cout when
log_to_console is enabled (the common case), bypassing Java's
System.out entirely. In the Java bindings, that raw write to the
process's native stdout stream corrupts Maven Surefire's forked-JVM
IPC protocol, which also uses stdout as its channel -- intermittently
turning a passing test run into a reported "VM crash" depending on
whether a log line happens to interleave with a protocol frame.
Reproduced locally: NativeIntegrationTest's PDLP/MIP solves reliably
trigger Surefire's "Corrupted channel by directly writing to native
stream" warning, occasionally escalating to a hard failure.

Add a console-sink override hook to the shared logger
(set_console_log_callback), used only when a caller registers one;
behavior for the Python, C, CLI, and server bindings is unchanged.
The Java JNI layer registers a callback that forwards each log line to
a new NativeLogSink.onLogLine, which writes it through System.out --
letting Surefire (and any other System.out interceptor, e.g. a
redirect or logging bridge) see it like ordinary Java output instead
of a raw native write.

Known residual gap: PSLP, a vendored third-party presolver linked
into libcuopt, prints its own status lines directly via printf and
does not go through cuopt's logger, so it is not covered by this
callback. It surfaces far less often than the fix's scope (only a
short presolve status line, versus the solver's console banner and
progress log on every solve), but is a separate, harder fix
(patching or forking the vendored library) tracked separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@ramakrishnap-nv ramakrishnap-nv self-assigned this Aug 28, 2026
@ramakrishnap-nv ramakrishnap-nv added non-breaking Introduces a non-breaking change improvement Improves an existing functionality labels Aug 28, 2026
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 9722612

ramakrishnap-nv and others added 2 commits August 28, 2026 16:48
Root-caused the residual Corrupted channel failures still hitting
java-static-test after the NativeLogSink fix: PSLP v0.0.11's
run_presolver() gates every other console message behind
stgs->verbose (print_start_message, print_end_message), but calls
print_infeas_or_unbnd_message() unconditionally when it detects the
problem is infeasible or unbounded. cuOpt already sets verbose =
false when calling PSLP (third_party_presolve.cpp), specifically to
keep it silent, so this one line slips through despite that and
writes straight to the process's native stdout -- bypassing
System.out exactly like the raw write NativeLogSink was built to
intercept, and corrupting Surefire's forked-JVM protocol the same
way.

The infeasible/unbounded status itself is unaffected: it already
flows back to the caller through run_presolver()'s typed return
value, not by parsing this printed text, so cuOpt's own (properly
routed) status reporting is unchanged.

Filed and fixed upstream: dance858/PSLP#55.
Until a release containing it is available, patch the vendored
v0.0.11 source at fetch time via a new PATCH_COMMAND on PSLP's
FetchContent_Declare.

Verified locally: rebuilt libcuopt_static + the JNI layer with the
patch applied (confirmed via the fetched source) and ran the full
Java suite, including ProblemIntegrationTest's infeasible-solve case
which is what triggers this code path, 50 times in a loop. Every run
passed with zero "Corrupted channel" occurrences (previously this
reproduced on the very first attempt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

improvement Improves an existing functionality non-breaking Introduces a non-breaking change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant