fix: make the Reactant-compiled solve loop terminate and restore reporting - #151
Merged
gdalle merged 3 commits intoSep 9, 2026
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests.
... and 17 files with indirect coverage changes 🚀 New features to boost your workflow:
|
…rting The `Core` tests fail on this branch in two places, and the compiled `solve!` never returns at all. Termination status cannot drive traced control flow. `termination_check!` returned `stats.termination_status !== MOI.OPTIMIZE_NOT_CALLED`, but `termination_status` is a plain `MOI.TerminationStatusCode` enum, so under Reactant that comparison folds to a constant at trace time. The generated HLO shows the outer loop returning `stablehlo.constant dense<false>` into its own predicate slot, i.e. an infinite loop. `set_termination_status!!` now also returns the decision as a (possibly traced) boolean, which is what the solve loops branch on. Its branches are reordered by increasing priority so that the last write wins, restoring the `OPTIMAL` > `TIME_LIMIT` > `ITERATION_LIMIT` precedence of the previous `if`/`elseif`. This also retires the unused `should_terminate!!`. The error history was never filled. The `push!` was commented out, and the `ConvergenceStats` default seeded `error_history` with the very same mutable `KKTErrors` held in `stats.err`, so the single entry was overwritten in place on every check. `first(...)` and `last(...)` therefore returned the same tuple, which is what `test/tutorial.jl` tripped on. The seed itself is worth keeping, since it gives `to_rarray` a non-empty vector to convert so that the field type and its value agree under tracing; it only needs to be `copy(err)` rather than an alias. Because `KKTErrors(sol)` is all `NaN`, `initialize` now fills the errors of the starting point before building the stats, so the history begins at a genuine measurement at zero KKT passes. Progress reporting and history recording are gated on `ReactantCore.within_compile()` rather than deleted, so both behave exactly as before on the CPU path and become no-ops inside a compiled region. This also fixes the stale `ProgressMeter` imports that `ExplicitImports` flagged; dropping the import instead would have traded that for an `Aqua` stale dependency, and dropping the dependency would have silently turned the public `show_progress` option into a no-op. Add an `Error history` testset so the tutorial is no longer the only thing covering this: it checks that the history grows, is indexed by KKT passes, ends at `stats.kkt_passes`, holds independent snapshots rather than aliases, starts from a finite measurement, and stays at just the seed when `record_error_history = false`. Against the previous code it fails ten ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WdrfQSAANYeyin33qaW6F4
The test jobs inherited the default 6 hour limit, which is how the Reactant job ran for 1h17m before the concurrency rule cancelled the whole workflow. Coverage instrumentation also applies to Reactant's tracing machinery, and measurably dominates that group: locally the Reactant group takes 4m10s with `--code-coverage=user` against 2m28s without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WdrfQSAANYeyin33qaW6F4
gdalle-bot
force-pushed
the
fix/reactant-progress-error-history
branch
from
September 8, 2026 22:37
89005b5 to
472f77f
Compare
gdalle
approved these changes
Sep 9, 2026
gdalle
reviewed
Sep 9, 2026
Co-authored-by: Guillaume Dalle <22795598+gdalle@users.noreply.github.com>
gdalle
marked this pull request as ready for review
September 9, 2026 06:31
gdalle
merged commit Sep 9, 2026
b1c7353
into
JuliaDecisionFocusedLearning:gd/reac2
11 of 12 checks passed
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
Targets
gd/reac2(#150), notmain.Fixes the two
Corefailures on that branch, and a third problem they were hiding: the compiledsolve!never returns.1. The compiled solve loop never terminates
termination_check!returnedstats.termination_status !== MOI.OPTIMIZE_NOT_CALLED, buttermination_statusis a plainMOI.TerminationStatusCodeenum, so under Reactant that comparison is folded to a constant at trace time. The generated HLO shows it plainly — the outer loop's predicate isstablehlo.not %iterArg, and the body returns a constantfalseinto that same slot:So the predicate is always true. Compilation itself was fine (~90s); execution simply never came back — which is why the
Reactantjob hung rather than failed.set_termination_status!!now also returns the decision as a possibly-traced boolean, and that is what the solve loops branch on. The deadshould_terminate!!was already exactly this shape and is retired in favour of it.While reordering those branches I also restored the precedence that the pre-refactor
if/elseifhad. The three@trace ifs were in order optimal → time → iteration, and since the last write wins,ITERATION_LIMITwas silently overridingOPTIMALwhenever both held. They are now ordered by increasing priority.2. The error history is never filled
This is what
test/tutorial.jl:176tripped on:The optimization is not stuck —
3.84e-7is below the requested1e-6. The two values are identical becauseerror_historyheld exactly one element, sofirstandlastreturned the same tuple.Two causes. The
push!intermination_check!was commented out, so nothing was ever appended. And theConvergenceStatsdefault seeded the vector with the same mutableKKTErrorsobject stored instats.err, so that one entry was overwritten in place bykkt_errors!on every check.The seed itself is worth keeping — it is what gives
to_rarraya non-empty vector to convert, so the field type and its value agree under tracing. It just needs to be a snapshot rather than an alias:That alone is not enough, because
KKTErrors(sol)fills withNaNandinitializepassed exactly that, which would leavefirst_err == NaNand the comparison false. Soinitializenow fills the errors of the starting point before building the stats. The history therefore starts at a genuine measurement at 0 KKT passes, which is also what a convergence plot wants. For PDLP this is the same singlekkt_errors!call it already made forerr_restart(now copied from it); for PDHG it adds one evaluation at initialization.New tests
The tutorial should not have been the only thing standing between this and
main, sotest/components/termination.jlgets anError historytestset covering each failure mode independently: that the history actually grows, that it is indexed by KKT passes and ends atstats.kkt_passes, that consecutive entries differ, that the last entry matches the final live errors, that entries are independent snapshots (mutatingstats.errafterwards must not rewrite them), that the seed is finite rather thanNaN, and thatrecord_error_history = falseleaves just the seed.Against the code on
gd/reac2these fail 10 ways, on five independent assertions:3. Stale
ProgressMeterimportsThe other
Corefailure: the progress bar was dropped from bothsolve!methods, leavingProgressUnknown,finish!andnext!imported but unused. Deleting the import only trades it for anAquastale-dependency failure, and dropping the dependency would silently turn the publicshow_progressoption (used by the MOI wrapper via:show_progress => !dest.silent) into a no-op.So instead of deleting either feature, the progress bar and the history recording are gated on
ReactantCore.within_compile()— no-ops inside a compiled region, unchanged on the CPU path:This costs nothing when Reactant is not compiling:
within_compile()is@inlined tofalse, soBase.infer_return_type(init_progress, Tuple{String, Bool})isProgressUnknown, not aUnion.4. CI
timeout-minutes: 45on the test job — it inherited the 6-hour default, which is how the Reactant job ran 1h17m before the concurrency rule cancelled the workflow. Coverage is also skipped for the Reactant group, since the instrumentation covers Reactant's tracing machinery: 4m10s with coverage vs 2m28s without, measured locally.On making Reactant compilation faster
Measured rather than assumed, and two of the obvious levers turned out not to work:
afiro32×27 vs25fv471571×821)@compile optimize = :none'enzyme.batch' op unsupported op for export to XLA. The optimization passes are what lower Enzyme ops into exportable StableHLO.I did not lower Julia's own optimization level: Reactant traces through Julia's abstract interpreter, so it depends on inference running normally, and
--compile=minin particular tends to break that.The honest summary is that compilation was never the problem — it is ~90s for PDHG and ~107s for PDLP regardless. The group appeared to take forever because it was executing an infinite loop. It now runs in 2m28s.
Verification
All run locally on top of
b75745b:CoreReactantMOItermination.jl(new testset)runic --check src/(The exact totals wobble between runs: the
milp.jltestset at line 135 samples 20 random Netlib instances viarandperm, and instances on its skip list contribute one@test_skipinstead of three passes. Nothing to do with these changes.)error_historynow matchesmainexactly on the same random instance (1000 entries,first_err = 0.972…,last_err = 192.20…, no aliasing).🤖 Generated with Claude Code
https://claude.ai/code/session_01WdrfQSAANYeyin33qaW6F4