test: reactivate @stable and check Reactant/plain coherence - #153
Merged
gdalle merged 4 commits intoSep 9, 2026
Conversation
Reactant support removed `@stable` from the whole package. Restore it: the plain Julia code paths are all type-stable, and Reactant tracing only defeats inference in a single place, `colsum!!` on a vector, which is opted out with `@unstable`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6
Run PDHG and PDLP twice from the same starting point, once with the plain Julia loop and once with a Reactant-compiled one, then compare the number of KKT passes, the primal-dual solution and the KKT errors. Cover `Float32` (loose tolerance) and `Float64` (tight tolerance). The time limit is left out of these configs: the compiled loop cannot call `time()` at each iteration, so a binding time limit would stop the two runs after different numbers of iterations. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6
Codecov Report✅ All modified and coverable lines are covered by tests.
... and 16 files with indirect coverage changes 🚀 New features to boost your workflow:
|
`maybe_restart!` existed only to hold the `@trace if` that ReactantCore cannot nest inside the `@trace while` of `solve!`. With `@stable` back on, that extra layer made the `solve!` → `maybe_restart!` → `restart!` chain too deep for inference to see through every DispatchDoctor wrapper: the call to `restart!` degraded to a dynamic dispatch, which boxed the (isbits, 144-byte) `algo` on every restart and broke the allocation-free `solve!` test. Move the guard into `restart!` itself, as a defaulted third argument. The branch still sits in a function of its own, as ReactantCore requires, but the chain is one layer shorter and the call resolves statically again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6
Its `@ref` from `restart!`'s new docstring could not resolve, because the function had no docstring of its own for `@autodocs` to pick up, which failed the documentation build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6
gdalle
approved these changes
Sep 9, 2026
gdalle
marked this pull request as ready for review
September 9, 2026 10:45
gdalle
merged commit Sep 9, 2026
4af16fc
into
JuliaDecisionFocusedLearning:gd/reac2
13 checks passed
This was referenced Sep 9, 2026
gdalle
pushed a commit
that referenced
this pull request
Sep 10, 2026
…156) `ConvergenceStats.termination_status` was a plain `MOI.TerminationStatusCode`, written from inside three `@trace if` blocks. An enum cannot be traced, so only the trace-time value of those assignments survived: a compiled run that converged still reported `MOI.OPTIMIZE_NOT_CALLED`, and `max_kkt_passes` / `time_limit` decided when to stop without ever saying so. Store the status as an integer code instead. `termination_status_code` is an ordinary number, so `to_rarray` tracks it and the compiled program writes it like any other stat, and `termination_status(stats)` decodes it back to the enum. `solve` still returns the same `MOI.TerminationStatusCode` it always did, one call away. That also removes the `elseif` workaround. With a traceable code, the criteria can be selected with nested `ifelse` calls instead of three separate `@trace if` blocks ordered by increasing priority and relying on last-write-wins (EnzymeAD/Reactant.jl#2563), so the priority between simultaneous criteria is stated in one place. The Reactant coherence tests now compare statuses between the plain and compiled runs, which #153 had to leave out, and the time limit test checks that the compiled solve reports `MOI.TIME_LIMIT` rather than just stopping at the right time. Verified locally on CPU: all three statuses (`OPTIMAL`, `ITERATION_LIMIT`, `TIME_LIMIT`) come back out of a compiled `solve!` for both PDHG and PDLP, and the `Core`, `MOI` and `Reactant` test groups pass. Claude-Session: https://claude.ai/code/session_01JPzdTAQVAXUNUAQsL4Nz71 Co-authored-by: Claude Opus 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.
This PR was opened by a coding agent, please disregard it until @gdalle has reviewed it
Stacked on top of #150 (targets
gd/reac2).Reactivate
DispatchDoctor.@stable#150 dropped
DispatchDoctorentirely. It is back, wrapping the same set of includes as onmain. All the plain Julia code paths turn out to still be type-stable, and Reactant tracing defeats inference in exactly one place:colsum!!(::Number, ::AbstractVector), because Reactant inferssum(::TracedRArray{T, 1})asUnion{TracedRArray, TracedRNumber}.That single method is opted out with
@unstableand a comment saying why.Keep the restart call out of dynamic dispatch
Turning
@stableback on broke the allocation-freesolve!test for PDLP (Perfgroup): every restart allocated a boxed copy ofalgo.maybe_restart!existed only to hold the@trace ifthat ReactantCore cannot nest inside the@trace whileofsolve!. With the DispatchDoctor wrappers back, that extra layer made thesolve!→maybe_restart!→restart!chain too deep for inference to see through, so the call torestart!degraded to adynamic invoke— which boxes the isbits, 144-bytealgoat every restart.The guard now lives inside
restart!itself, as a defaulted third argument. The branch still sits in a function of its own, as ReactantCore requires, but the chain is one layer shorter and the call resolves statically again. Verified: PDLPsolve!goes back to allocating exactly the progress bar and nothing else.Reactant/plain coherence tests
test/gpu/reactant/runtests.jlnow solves the same Netlib instance twice from the same starting point — once with the plain Julia loop, once with a Reactant-compiled one — and compares:KKTErrors.Covered for PDHG and PDLP, in
Float32(rtol = 1e-3) andFloat64(rtol = 1e-8). Observed agreement is much tighter than the tolerances: ~4e-4 relative on the errors inFloat32, ~3e-12 inFloat64. The old "iterates are finite" checks are kept as a sub-testset.time_limitis left out of these configs: the compiled loop cannot calltime()at each iteration, so a binding time limit would stop the two runs after different numbers of iterations.max_kkt_passesbounds the runtime instead.Note for review
While writing these tests I noticed that
stats.termination_statusis not propagated by the compiled loop: it staysMOI.OPTIMIZE_NOT_CALLEDeven when the compiled run stops on the tolerance at the same iteration as the plain run (which reportsMOI.OPTIMAL). The enum is a plain Julia field assigned inside@trace if, so only its trace-time value survives. I left that alone — it is a design question beyond the scope of this PR — and the tests therefore do not compare termination statuses.Test runs
CoreMOIPerfReactant🤖 Generated with Claude Code
https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6