Skip to content

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

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

Explore self-contained Java classifier JARs with a statically linked libcuopt#1818
ramakrishnap-nv wants to merge 30 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 5 test job(s) skipped.

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>
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 6f37f3d

…sh diagnostics inline

Root cause of java-static-test's flaky Surefire "Corrupted channel"
crash, found by reproducing it locally against a self-contained
classifier JAR inside the real rapidsai/ci-conda container (a plain
-Dcuopt.native.dir run never hit it, because that path's broader
LD_LIBRARY_PATH happened to expose the missing library anyway):

libcudss.so.0 dlopen()s a separate OpenMP threading backend,
libcudss_mtlayer_gomp.so.0, from cudssSetThreadingLayer at runtime.
That companion was missing from both NativeLibraryLoader's embedded-
resource list and build_cuopt_java_jar.sh's packaging step, so in a
genuinely consumer-like environment (no libcuopt, no broader
LD_LIBRARY_PATH -- exactly what java-static-test runs) the call fails
and cuDSS writes its own failure message straight to the process's
native stdout:

  FAILED: CUDSS call ended unsuccessfully with status = 3, details:
  "cudssSetThreadingLayer"

That's a raw write cuOpt's own logger never sees, so no amount of
NativeLogSink/PSLP fixing (see #1825) could catch it -- three
independent sources were writing to the same stream. Verified with 3
clean runs against the container repro after packaging the missing
library.

Also dump target/surefire-reports/*.dumpstream and hs_err_pid*.log
inline in ci/test_java_static.sh on failure, and upload
surefire-reports as a job artifact: this exact diagnosis depended on
reading the dumpstream file's contents, which neither the console log
nor any uploaded artifact previously exposed.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 4bc2d27

…se the crash

Both were temporary: the fprintf diagnostics in logger.cpp/cuopt_jni.cpp
confirmed NativeLogSink's callback routing works correctly on every
solve (never falls back to raw std::cout, never skips reconfiguring an
existing guard), which ruled out this PR's own logging path as the
remaining cause of java-static-test's Surefire "Corrupted channel"
crash. The actual cause -- cuDSS's missing OpenMP threading-layer
companion library -- is fixed independently in 4bc2d27 and verified
clean against a local reproduction of the real CI container three
times, with and without this debug code.

pr.yaml's non-java if:false gates were only there to keep iteration on
java-static-test fast; restored to the normal changed-files-gated
conditions, keeping the surefire-reports artifact upload added
alongside the real fix.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test d468b48

…d.yaml

The java-static-* jobs are no longer exploratory, and the comments
duplicated what's already explained at each script's own top-of-file
#1817 reference. Kept the one bit of load-bearing rationale (why
java-static-build-matrix skips conda-cpp-build) inline in pr.yaml.

build.yaml also had "Combines every classifier into one
Maven-repository-layout artifact... See rapidsai/build-infra#379"
sitting above java-static-test, describing java-static-gather instead
(25 lines further down, which had no comment of its own). Moved it to
the job it actually documents.
java-static-test has repeatedly hit 429 Too Many Requests resolving
plugins like maven-source-plugin from a cold repository -- the
cuopt_mvn wrapper's retry loop (fixed in #1823) retries the whole mvn
invocation with backoff, but that's compensating for Maven's own
resolver never being tuned, and 4 attempts don't reliably outlast a
sustained rate limit.

cuDF and cuVS already carry this exact fix for their own Java/Maven
Central builds: a project-level .mvn/maven.config (auto-applied to
every mvn invocation, no wrapper needed) that caps concurrent
downloads to reduce burst request rate and adds a real backoff inside
Maven's own transport-layer retry handler, rather than only retrying
around the outside of a failed process:

  -Daether.connector.basic.downstreamThreads=1
  -Daether.transport.http.retryHandler.count=5
  -Daether.transport.http.retryHandler.interval=10000
  -Dmaven.wagon.http.retryHandler.count=5

cuopt_mvn's own -D flags target the connector-layer retry handler,
which recent Maven resolver versions may no longer consult now that
retry logic lives at the transport layer -- this adds the layer that
actually gets read, verified via `mvn help:evaluate
-Dexpression=aether.transport.http.retryHandler.interval` resolving
to 10000. Verified the packaged-jar-tests suite still passes with
this config present.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 88b49bd

…rim pr.yaml to iterate

Root cause investigation (see PR discussion): java-static-test's
Surefire "Corrupted channel" crash was fixed by shipping cuDSS's
missing threading-layer companion, but java-static-test also kept
hitting Maven Central 429s that java-static-build didn't. The
difference traced to test_java_static.sh doing a fresh `conda create`
(openjdk+maven+cuda-version+libcublas+libcusparse) every run -- a slow
solve that reliably lined up concurrent matrix jobs' cold Maven
Central resolution moments apart, exactly the kind of synchronized
retry collision a fixed backoff schedule can't escape.

cuDF's java-tests job (same shared-workflows custom-job.yaml, same
kind of packaged-jar test) doesn't hit this: it runs on
rapidsai/ci-wheel, a plain CUDA-devel + dnf environment with no conda
env-solve step at all. The test job only needs a JDK, Maven and the
CUDA runtime (libcublas/libcusparse, already baked into that image);
everything else the JAR needs is already embedded as a companion
library, so there's nothing conda-specific about testing it.

- test_java_static.sh: dnf-install a JDK (Rocky 8's own maven package
  is too old for cuOpt's plugins, so pin a modern Apache Maven tarball
  instead) rather than solving a conda env from scratch.
- pr.yaml / build.yaml: point java-static-test's container_image at
  rapidsai/ci-wheel instead of rapidsai/ci-conda. java-static-build
  stays on conda unchanged -- unlike the test job, the build needs
  TBB, which has no clean dnf/pip source for both amd64 and arm64
  (Rocky 8's dnf tbb-devel is the ancient 2018 API; the pip and GitHub
  release prebuilt binaries are x86_64-only).
- Verified locally end to end against the actual rapidsai/ci-wheel
  image via Docker (not just -Dcuopt.native.dir): building the
  classifier JAR with conda unchanged, then running
  -Ppackaged-jar-tests inside a fresh rapidsai/ci-wheel container with
  zero conda. That surfaced two more build-toolchain/consumer-distro
  ABI gaps invisible on the current Ubuntu-based test image: libgomp,
  libstdc++ and libgcc_s all need newer symbol versions
  (OMP_5.0.1/GLIBCXX_3.4.30/GCC_14.0.0) than Rocky Linux 8's defaults
  ship. Fixed by shipping all three as companions, same pattern as
  librmm.so/libtbb.so.12/libcudss.so.0. Final local run: 38/38 tests,
  zero Corrupted channel, zero UnsatisfiedLinkError.

pr.yaml's non-java if:false gates are back for the same reason as
before: keep iterating on java-static-test alone instead of the full
suite. Revert before merge.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 2dad31c

@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 2dad31c

… mvn test/verify

Root cause of why cuDF's equivalent java-tests job never seems to hit
the same Maven Central 429s ours does, even after moving to the same
rapidsai/ci-wheel environment: cuDF gates maven-source-plugin and
maven-javadoc-plugin behind a release-only profile, so their test
path never resolves them. Ours declared both unconditionally in
<build><plugins>, so every `mvn test`/`verify` invocation -- including
test_java_static.sh's, which never packages anything -- still had to
resolve maven-source-plugin:3.3.1 from Maven Central on every run.
That's the exact artifact that's failed on 429 in every occurrence of
this issue across this investigation.

Move both plugins into a new attach-source-javadoc profile, activated
explicitly by ci/build_cuopt_java_jar.sh (the only place that actually
needs sources/javadoc jars, for Maven Central publishing) rather than
left implicit everywhere.

Verified locally: `mvn test -Ppackaged-jar-tests` and `mvn compile`
no longer resolve maven-source-plugin/maven-javadoc-plugin at all
(checked via -X debug output); `mvn package -Pattach-source-javadoc`
still produces both jars; the full packaged-jar-tests suite still
passes (38/38).
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test f373279

ramakrishnap-nv added a commit that referenced this pull request Sep 1, 2026
…y mvn test/verify

maven-source-plugin and maven-javadoc-plugin were declared
unconditionally in <build><plugins>, so every `mvn test`/`verify`
invocation -- including ci/test_java.sh's, which never packages
anything for publishing -- still had to resolve
maven-source-plugin:3.3.1 from Maven Central on every run. That's the
same artifact that's repeatedly failed on 429 in the java-static-test
investigation on #1818, and there's no reason java-build's plain test
path should pay for it either.

No script on this branch currently packages a JAR for real
publishing (only java/cuopt/scripts/test.sh's `mvn verify`, for
testing, and build.sh's `mvn clean package`, for local dev -- neither
needs sources/javadoc jars), so nothing needs the new
attach-source-javadoc profile activated; this is a pure reduction in
unnecessary Maven Central resolution.

Verified locally: `mvn compile` no longer resolves
maven-source-plugin/maven-javadoc-plugin at all (checked via -X debug
output).
…rs-mergefix

# Conflicts:
#	cpp/src/utilities/logger.cpp
#	cpp/src/utilities/logger.hpp
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 458b062

ramakrishnap-nv added a commit that referenced this pull request Sep 1, 2026
The stray-infeasible-message fix is a real, separate bug (independently
confirmed via a captured Surefire dumpstream, and fixed upstream at
dance858/PSLP#55), not something the cuDSS fix on #1818 makes
redundant -- but it's a distinct concern from this PR's own scope
(routing cuOpt's console logging through System.out) and belongs in
its own PR for review.
ramakrishnap-nv added a commit that referenced this pull request Sep 1, 2026
run_presolver() prints "PSLP declares problem as infeasible[.| or
unbounded.]" unconditionally, unlike every other console message in
that function, which are all gated on stgs->verbose. cuOpt sets
verbose = false when calling PSLP (third_party_presolve.cpp)
specifically to keep it silent, so this writes unexpectedly straight
to the process's native stdout.

Found and confirmed via a captured Surefire dumpstream while
investigating the Java bindings' "Corrupted channel" crash (see
#1818): a raw native write there bypasses System.out and
corrupts Maven Surefire's forked-JVM protocol, which also uses stdout
as its own channel. 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.

Filed and fixed upstream: dance858/PSLP#55.
Pin past v0.0.11 to the merge commit until a release containing it is
available.
rapids-bot Bot pushed a commit that referenced this pull request Sep 1, 2026
#1825)

## Summary

- cuOpt's C++ logger writes console output (`log_to_console` == true, the common case) directly to `std::cout`, which performs a raw write to the process's native stdout file descriptor -- completely bypassing Java's `System.out`.
- In the Java bindings, that corrupts Maven Surefire's forked-JVM communication protocol, which also multiplexes over stdout. Depending on whether a log line happens to land mid-frame, this shows up as anything from a harmless `Corrupted channel by directly writing to native stream` warning to a full `VM crash or System.exit called?` failure that fails every remaining test in that fork.
- This is not new: the same warning already fires (non-fatally, so far) in the existing `java-build` CI job today. It surfaced as a hard failure while investigating [#1818](#1818) (exploratory static-linked classifier JARs), whose `java-static-test` job hit the race far more reliably than `java-build` does. Root-caused and reproduced locally by rebuilding libcuopt + the JNI layer and running `NativeIntegrationTest` directly; see that PR's discussion for the investigation.

## Fix

- Add a console-sink override hook to the shared logger, `cuopt::set_console_log_callback` (`cpp/src/utilities/logger.hpp`/`.cpp`). Unused by default, so behavior for the Python, C, CLI, and server bindings is unchanged.
- The Java JNI layer (`cuopt_jni.cpp`) registers a callback (lazily, on first `SolverSettings` creation, so `FindClass` runs with the right classloader) that forwards each log line to a new `NativeLogSink.onLogLine`, which writes it through `System.out`.
- Because the line now goes through `System.out`, Surefire's own interception of that stream (and any other consumer's -- a redirect, a logging bridge) sees it as ordinary Java output rather than a raw native write, so there is nothing left to corrupt its IPC channel.

## Second source found and fixed: vendored PSLP

After the fix above, `java-static-test` still failed occasionally with the same signature. Reproduced locally (stress loop, hit on the first attempt) and read the exact corrupted text straight from Surefire's `.dumpstream` artifact: `PSLP declares problem as infeasible.` -- a raw `printf` in the vendored PSLP presolver (`github.com/dance858/PSLP`, pinned at `v0.0.11`).

Root cause: `run_presolver()` gates every other console message behind `stgs->verbose` (`print_start_message`, `print_end_message`), but calls `print_infeas_or_unbnd_message()` unconditionally. cuOpt already sets `verbose = false` when calling PSLP (`third_party_presolve.cpp`) specifically to keep it silent -- this one line was just missed. 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.

Filed and fixed upstream: dance858/PSLP#55. Until a release containing it is available, `cpp/CMakeLists.txt` patches the vendored `v0.0.11` source at fetch time via a new `PATCH_COMMAND` on PSLP's `FetchContent_Declare` (patch file at `cpp/cmake/patches/pslp/respect_verbose_for_infeasible_message.patch`).

## Test plan

- [x] Rebuilt libcuopt (static) and the JNI layer with the `NativeLogSink` change, in a clean CI-matched conda env; ran `NativeIntegrationTest` directly via `mvn test -Dcuopt.native.dir=...`.
  - Before: `Tests run: 11, Failures: 0, Errors: 0` followed by `Corrupted channel by directly writing to native stream in forked JVM 1` for every solver log line (`cuOpt version: ...`, `Setting parameter ...`, `Solving a problem with ...`).
  - After: same 11/11 pass, zero occurrences of `Corrupted channel`.
- [x] Ran the full Java suite (35 tests) with just the `NativeLogSink` fix: all pass, but one residual `Corrupted channel` warning remained, traced to PSLP's `printf` (see above).
- [x] After adding the PSLP patch: rebuilt libcuopt_static + the JNI layer with the patch applied (confirmed applied by inspecting the fetched source), and ran the full Java suite in a loop 50 times, including `ProblemIntegrationTest`'s infeasible-solve case which is what exercises this exact code path. All 50 runs passed with zero `Corrupted channel` occurrences.
- [ ] `java-build` CI passes with no `Corrupted channel` warning at all.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Authors:
  - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv)

Approvers:
  - Chris Maes (https://github.com/chris-maes)
  - Rajesh Gandham (https://github.com/rg20)
  - Trevor McKay (https://github.com/tmckayus)

URL: #1825
…perty namespace

Two independent fixes surfaced by the first real CI run of the
rapidsai/ci-wheel migration:

arm64 java-static-test failed with UnsatisfiedLinkError: libcublas.so.13
not found, despite the image shipping it and ldconfig registering it
correctly (verified directly against the published image). arm64
images carry both a targets/aarch64-linux and a targets/sbsa-linux
directory; only sbsa-linux actually has the libraries, and glob/find
order put the empty aarch64-linux one first. Rather than depend on the
image's implicit ldconfig setup at all (whatever the exact runner-side
difference turns out to be), explicitly find the target directory that
actually contains libcublas.so and put it on LD_LIBRARY_PATH.

Separately, java-static-test hit a 429 on a different plugin
(exec-maven-plugin, needed by generate-sources so it can't be moved
into the attach-source-javadoc profile like the other two). Checked
.mvn/maven.config's properties against the actual MRESOLVER-396
source (ConfigurationProperties.java) again: aether.transport.http.retryHandler.*
(copied from cuDF/cuVS) isn't the namespace that feature's
count/interval/serviceUnavailable properties live under -- it's
aether.connector.http.retryHandler.*. Verified via
`mvn help:evaluate` that all four now actually resolve, matching the
real property names this time instead of a second guess.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test c1f9a15

rapids-bot Bot pushed a commit that referenced this pull request Sep 1, 2026
… stdout (#1836)

## Summary

- `run_presolver()` in vendored PSLP (`dance858/PSLP`, pinned at `v0.0.11`) prints `"PSLP declares problem as infeasible[.| or unbounded.]"` unconditionally, unlike every other console message in that function, which are all gated behind `stgs->verbose` (`print_start_message`, `print_end_message`, etc).
- cuOpt already sets `verbose = false` when calling PSLP (`third_party_presolve.cpp`) specifically to keep it silent -- this one message was just missed upstream, so it writes straight to the process's native stdout regardless.
- Found while investigating the Java bindings' "Corrupted channel" Surefire crash on #1818: a raw native write there bypasses `System.out` and corrupts Maven Surefire's forked-JVM protocol, which also multiplexes over stdout. Confirmed directly by reading the exact corrupted text out of a captured Surefire `.dumpstream` artifact.
- The infeasible/unbounded status itself is unaffected by this fix -- it already flows back to the caller through `run_presolver()`'s typed return value, not by parsing this printed text.

## Fix

Filed and fixed upstream: dance858/PSLP#55. Until a release containing it ships, pin `cpp/CMakeLists.txt`'s `GIT_TAG` past `v0.0.11` directly at the merge commit, rather than patching the vendored source locally.

## Test plan

- [x] Confirmed via a captured `.dumpstream` artifact that this exact message was the corrupted text.
- [x] `ProblemIntegrationTest`'s infeasible-solve test case (Java bindings) exercises this code path directly.
- [ ] CI passes clean.

Split out of #1825, which originally carried this alongside an unrelated fix (routing cuOpt's own console logging through `System.out`).

Authors:
  - Ramakrishna Prabhu (https://github.com/ramakrishnap-nv)

Approvers:
  - Trevor McKay (https://github.com/tmckayus)

URL: #1836
Without this, RAPIDS_CUDA_VERSION inside the container stayed at
rapidsai/ci-conda:26.10-latest's baked-in default for every matrix entry,
since custom-job.yaml has no other per-matrix env passthrough. All four
build matrix entries were actually building against the same CUDA version
regardless of label; ci/build_java_static.sh reads RAPIDS_CUDA_VERSION to
pick the conda CUDA toolkit, so the cu12-labeled classifier JARs were
really cu13 builds. That surfaced as an arm64 UnsatisfiedLinkError
(libcublas.so.13 missing) when java-static-test correctly pulled a genuine
CUDA 12.9 image to test what it thought was a CUDA 12.9 build.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test 0002a06

…erride

container-options: -e RAPIDS_CUDA_VERSION=... worked, but conda-cpp-build.yaml
already establishes the idiomatic way to thread matrix.CUDA_VER into an
image without custom-job.yaml's native support for it: pin the image tag
itself, the same way java-static-test already does for rapidsai/ci-wheel.
Confirmed the cuda-pinned ci-conda tags exist for both CUDA_VER values in
this matrix.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test ee920a8

NVIDIA-managed GitHub Actions runners egress through a small, shared NAT'd
IP range, so every RAPIDS repo's Java CI shares the same rate-limit budget
against Maven Central (rapidsai/build-infra#370): cuDF, cuVS, cuVS-lucene,
and kvikio have all hit the same 429s we're seeing on exec-maven-plugin.
kvikio#992 fixed it there by preferring the read-only GCS mirror of Central
(the same one Apache ORC/Lucene/Spark use) with Central as fallback.
Applying the same fix here. Verified locally: every plugin, including
exec-maven-plugin, now resolves from the GCS mirror.
@ramakrishnap-nv

Copy link
Copy Markdown
Collaborator Author

/ok to test ed2fc5e

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