Explore self-contained Java classifier JARs with a statically linked libcuopt - #1818
Draft
ramakrishnap-nv wants to merge 16 commits into
Draft
Explore self-contained Java classifier JARs with a statically linked libcuopt#1818ramakrishnap-nv wants to merge 16 commits into
ramakrishnap-nv wants to merge 16 commits into
Conversation
|
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
force-pushed
the
java-static-classifiers
branch
from
August 27, 2026 18:27
dd6ae68 to
e521ce7
Compare
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>
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>
Collaborator
Author
|
/ok to test 87b17bb |
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>
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>
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>
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>
Collaborator
Author
|
/ok to test 78822d7 |
4 tasks
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>
Collaborator
Author
|
/ok to test 9722612 |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.Result
cuopt-26.10.0-cuda13.jar, 405 MB, built for CUDA arch 75 with routing and gRPC excluded:libnccl.so.2libcuopt_jni.solibcudss.so.0librmm.so,libtbb.so.12,librapids_logger.soAgainst 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.sofiles.What static linking actually removed, and what it did not
-DBUILD_SHARED_LIBS=OFFdoes not work here:cuoptis declaredadd_library(cuopt SHARED ...), so the flag is ignored.cuopt_staticalready existed but only insideif (BUILD_TESTS); it is now gated onBUILD_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 separateUnsatisfiedLinkError, one per rebuild:_ZTIN3rmm10_RMM_26_109bad_allocE— rmm's exception typeinfotbb::detail::r1::throw_exception— TBB, via KaMinParncclGroupStart— NCCLcudssDestroy— cuDSSThey are linked and packaged beside the JNI library, which resolves them through its existing
$ORIGINRPATH.NCCL
NCCL is 69% of the JAR and cannot be reached from Java:
pdhg.hppholdsmulti_gpu_engine_t*as a forward-declared pointer defaulting tonullptr, and the whole surface is 14 symbols across 5 files undercpp/src/pdlp/distributed_pdlp/.Findings from investigating whether it could be linked statically instead:
ncclships no static archive — onlylibnccl.so{,.2,.2.30.7},libnccl_device.bcand a pkgconfig file.libnccl_static.aexists only in apt'slibnccl-dev, which would mean sourcing one dependency from apt while the rest comes from conda.nvidia/cuda:13.0.3-devel,libnccl_static.ais 190 MB against 181 MB for the shared library. NCCL's bulk is per-architecture device code, which comes along either way.nvidia/cuda:13.0.3-basehas none; the devel image gets it from thelibnccl2apt package at/lib/x86_64-linux-gnu, not/usr/local/cuda/lib64. So it cannot be treated as consumer-supplied the way cuBLAS can.dependencies.yamlfor both paths —nccl >=2.19underbuild_cpp,nvidia-nccl-cu1{2,3}>=2.19undercuda_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
dlopenwould do the same for conda and wheel users, who currently carry ~280 MB for a feature most never use.cpp/CMakeLists.txthas no such switch today, unlikeSKIP_ROUTING_BUILDandSKIP_GRPC_BUILD.CI
java-static-buildrunsci/build_java_static.shend to end — static libcuopt, static link, package, verify — inbuild.yamlandpr.yaml, gated on the same file groups asjava-buildand part of thepr-builderaggregator.verify_jar_dependencies.shis the check worth having. It readsDT_NEEDEDfor every packaged library and allows only what is inside the JAR, provided by the CUDA toolkit, or part of the base system.Reading
DT_NEEDEDrather than resolving against a library directory is the point. The first version pointedlddat the conda prefix and passed a JAR withlibnccl.so.2deleted, because the prefix contains it either way — the build environment makes any JAR look self-contained. It now fails correctly:It also fails if
libcuopt.soreappears inDT_NEEDED, which would mean the static link silently fell back to shared.Not regressed
The shared
libcuoptpath is untouched.CUOPT_STATIC_BUILD_DIRandCUOPT_BUILD_STATIC_LIBare both empty/off by default,-Dcuopt.native.diris still the loader's first strategy, and./build.sh java --run-java-testspasses 35/35 producing an unclassified JAR.Open questions
libcuoptfailing to load on a plain CUDA runtime image.Checklist
verify_jar_dependencies.shtested against a deliberately broken JAR