Skip to content

test: reactivate @stable and check Reactant/plain coherence - #153

Merged
gdalle merged 4 commits into
JuliaDecisionFocusedLearning:gd/reac2from
gdalle-bot:claude/stable-and-reactant-coherence
Sep 9, 2026
Merged

gdalle merged 4 commits into
JuliaDecisionFocusedLearning:gd/reac2from
gdalle-bot:claude/stable-and-reactant-coherence

Conversation

@gdalle-bot

@gdalle-bot gdalle-bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

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 DispatchDoctor entirely. It is back, wrapping the same set of includes as on main. 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 infers sum(::TracedRArray{T, 1}) as Union{TracedRArray, TracedRNumber}.

That single method is opted out with @unstable and a comment saying why.

Keep the restart call out of dynamic dispatch

Turning @stable back on broke the allocation-free solve! test for PDLP (Perf group): every restart allocated a boxed copy of algo.

maybe_restart! existed only to hold the @trace if that ReactantCore cannot nest inside the @trace while of solve!. With the DispatchDoctor wrappers back, that extra layer made the solve! → maybe_restart! → restart! chain too deep for inference to see through, so the call to restart! degraded to a dynamic invoke — which boxes the isbits, 144-byte algo at 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: PDLP solve! goes back to allocating exactly the progress bar and nothing else.

Reactant/plain coherence tests

test/gpu/reactant/runtests.jl now 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:

  • the number of KKT passes (exactly equal: both loops must stop at the same point for the rest to mean anything),
  • the primal and dual iterates,
  • every field of the final KKTErrors.

Covered for PDHG and PDLP, in Float32 (rtol = 1e-3) and Float64 (rtol = 1e-8). Observed agreement is much tighter than the tolerances: ~4e-4 relative on the errors in Float32, ~3e-12 in Float64. The old "iterates are finite" checks are kept as a sub-testset.

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. max_kkt_passes bounds the runtime instead.

Note for review

While writing these tests I noticed that stats.termination_status is not propagated by the compiled loop: it stays MOI.OPTIMIZE_NOT_CALLED even when the compiled run stops on the tolerance at the same iteration as the plain run (which reports MOI.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

Group Result
Core 5679 pass, 1 broken
MOI 1827 pass
Perf 4 pass
Reactant 44 pass (6m37s)

🤖 Generated with Claude Code

https://claude.ai/code/session_01YGgoU6rLLT72Qis2Ku8pB6

gdalle-bot and others added 2 commits September 9, 2026 08:17
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

codecov Bot commented Sep 9, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/CoolPDLP.jl 100.00% <ø> (ø)
src/algorithms/pdlp.jl 96.62% <100.00%> (-1.20%) ⬇️
src/utils/linalg.jl 95.52% <100.00%> (-1.50%) ⬇️

... and 16 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

gdalle-bot and others added 2 commits September 9, 2026 09:33
`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
gdalle marked this pull request as ready for review September 9, 2026 10:45
@gdalle
gdalle merged commit 4af16fc into JuliaDecisionFocusedLearning:gd/reac2 Sep 9, 2026
13 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants