Add opt-in optimizer step observer - #78
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #78 +/- ##
==========================================
+ Coverage 81.53% 82.26% +0.73%
==========================================
Files 50 51 +1
Lines 2193 2301 +108
==========================================
+ Hits 1788 1893 +105
- Misses 405 408 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Copy the relevant workflow fix from dac1bf4 so CI builds the untracked TikZ assets before Documenter validates links.
The observer had a docstring stating the protocol but nothing stating the problem, so a reader met the mechanism before the question it answers. This adds a chapter that starts from the question: a referee asked what the geometric optimizers cost, the paper's standing claim is that the geometry is cheap next to automatic differentiation on a CPU and not on a GPU, and neither can be settled by a stopwatch around solver_step! and update!. The chapter says why the split cannot be recovered from outside the package -- the retraction is applied several times per step and once per line-search trial, and the objective once per trial, so a caller holding only the two public calls sees one total whose composition depends on how many trials the search took -- and why the package must not simply time itself: a host timestamp around an asynchronous kernel launch measures queueing, making it meaningful needs a device synchronization, and a package that synchronized on the caller's behalf would slow down every run that never asked to be measured. The clock and the bookkeeping are the caller's choice for the same reason. Reporting boundaries and nothing else is what leaves both with the caller. Then the protocol (matched pairs from a finally block, nesting, no influence on control flow), the three phases and why :objective is one of them (so that it can be subtracted rather than charged to the geometry), two executed examples -- the event trace of a step, and a stack-based exclusive-time observer -- the other uses the hook has, and a Coverage section for the three boundaries that are outside a phase. Verified: the Documenter HTML build passes with the new page and its two @example blocks execute; the only remaining warnings are the pre-existing api.md and retractions.md size warnings and the repolink warning. The footnote on cost is measured: @allocated over a warmed step is identical on this branch and on main at 297e0ed for GradientMethod, MomentumMethod, Adam and BFGS. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review — Claude (Opus 5) contributionReviewed at What I verified as sound
1.
|
Observe every direct objective evaluation performed by solve!, and thread the step observer through the BFGS/DFP end-of-iteration objective and retraction updates. Add regression coverage for quasi-Newton events, default Backtracking slope nesting, and traced/untraced solve! objective counts; update the observer guide accordingly.
|
Implemented the review follow-up in
Verification:
|
benedict-96
left a comment
There was a problem hiding this comment.
We should change the API somewhat. I agree that having Optimizer(...; observer = recorder) is the right way of incorporating observers, but leaving coding EventLog and/or PhaseTimer to the user seems to overcomplicate things. The user should be able to do recorder = Eventlog() or recorder = PhaseTimer(), with the option of recording more/less if they chose to do so.
| fraction of a step was spent differentiating and which was spent retracting. And the split cannot be | ||
| recovered from outside the package either, because the boundaries are not at the outside: | ||
|
|
||
| * the retraction is applied **more than once per step** — once per iteration of the ``\mathrm{NaN}`` |
There was a problem hiding this comment.
This is not true for Static linesearch, right? There it is only applied once.
There was a problem hiding this comment.
Addressed in 64772b3. The guide now explicitly separates step machinery from searching-line-search work: Static selects its fixed step without evaluating a trial, while only searching line searches add per-trial retraction and objective work. The non-line-search retraction/application boundaries in the NaN guard, accepted step, and state-section update still apply.
| using GeometricOptimizers: increase_iteration_number!, initialize_state!, solver_step!, | ||
| update! | ||
|
|
||
| mutable struct EventLog |
There was a problem hiding this comment.
Leaving coding a separate EventLog struct to the user seems to overcomplicate things. Could we not make EventLog a package internal (as well as the associated functor), and then just pass the recorder as a kwarg to the optimizer?
There was a problem hiding this comment.
Implemented in 64772b3. EventLog is now an exported package type, so the user can write recorder = EventLog() and pass observer=recorder. It supports optional phase filtering and empty! reuse; the regression tests cover both, and the guide now uses the built-in type.
| accumulated times are mutually exclusive. | ||
|
|
||
| ```@example observers | ||
| mutable struct PhaseTimer |
There was a problem hiding this comment.
Same as for the EventLog above: we should make this package internals.
There was a problem hiding this comment.
Implemented in 64772b3. PhaseTimer is now an exported package type, with PhaseTimer() as the zero-setup path, optional phase filtering, empty! reuse, a configurable clock, and a synchronization callback for GPU timings. Deterministic tests cover exclusive nesting, filtering, and synchronization, and the guide now uses the built-in timer directly.
|
Implemented the review in
I responded to each inline review comment with links to the relevant source, tests, and documentation. I also rewrote the PR description for the current API. Local validation on Julia 1.12.7: full package suite 9,135/9,135, observer suite 31/31, export suite 67/67, pre-commit formatter/package-load checks passed, and Documenter/doctests passed with only the pre-existing warnings. |
Both executed examples drove the optimizer by hand: they imported four internal names, built and initialized an `OptimizerState`, called `increase_iteration_number!`/`solver_step!`/`update!`, and bracketed the three in an `observe_optimizer_phase` block. `87abd69` made `solve!` emit every phase itself, so a one-iteration `solve!` now shows the same structure with none of that. The first example loses its internals import and eight lines; the second loses eleven and prints `timer.calls` directly instead of formatting it. `print_nested` takes `depth` as an argument rather than assigning a local: in a Documenter `@example` block a bare `for` loop that assigns to `depth` is top-level soft scope, so the counter has to live somewhere that is not a warning. The examples now run the default `Backtracking` line search, which is what a caller who passes no `linesearch` gets, so the trace shows the nested `:gradient` inside the slope request. The walkthrough is rewritten against the trace the blocks actually produce. Two accuracy fixes while in the file: `:retraction_application` is entered twice per slope request, so `calls` for it exceeds the number of retractions applied by one per request, and the `:objective` count per step is the number of trials plus the NaN guard's rather than the number of trials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review — Claude (Opus 5) contributionSecond pass, at I also pushed The previous round, verified closed
1.
|
Both executed examples lost their `using GeometricOptimizers: ...` import in `7dd75a7`, but the snippet showing how to add an outer phase still bracketed `solver_step!` and `GeometricOptimizers.update!` -- so the one thing on the page a reader would copy was also the one thing that needed internals. An outer phase does not care what is inside it, and `87abd69` made `solve!` report every inner phase, so bracketing `solve!` yields the same fourth number with nothing imported. The snippet now does that, and the timer example runs it rather than merely asserting it, which is why that example reports four phases instead of three. A sentence records that a caller who drives the loop itself brackets `solver_step!` and `update!` the same way. Naming the phase `:bookkeeping` rather than `:optimizer_state_direction`: around a whole `solve!` the remainder is the direction computation and the state and cache updates, but also the line search's control flow, the status construction and the convergence tests. The prose now lists all four. The durations stay unprinted, with the reason sharpened. It is not only the clock's resolution: `:bookkeeping` around a cold `solve!` is dominated by Julia compiling the run being measured -- measured 490 ms against microseconds for the three inner phases -- which is the warm-up caveat the section already gives, arriving one paragraph earlier than it used to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up — Claude (Opus 5) contributionPushed An outer phase does not care what is inside it, and since observe_optimizer_phase(observer, :bookkeeping) do
solve!(x, OptimizerState(method, x), opt)
endThe timer example now runs that instead of the page merely asserting it, which is why it reports four phases rather than three: A sentence records that a caller who drives the loop itself brackets Two details worth stating:
Both |
The section is titled "exclusive time per phase" and its example printed `timer.calls`, which is a count. So the output was four bare integers under a heading promising times, with the unit a paragraph away in prose -- and read as nanoseconds it says a gradient took three of them. `7dd75a7` caused this: the example it replaced printed `timer.calls[phase], " call(s)"`, and collapsing it to `timer.calls` dropped the label along with the formatting. Both numbers are now printed per phase, each with its unit, so the output answers the question without the prose. `print` on a `UInt64` is decimal, unlike `show`, so the loop also avoids displaying `timer.exclusive` as a `Dict` of `0x0000000000002544`. Printing durations at all requires the warm-up the section was only recommending one paragraph later. Without it `:bookkeeping` is where Julia's compilation of the run lands -- measured 490 ms against microseconds for the three inner phases -- which does not illustrate an accounting, it hides one. The example now runs `solve!` once, calls `empty!`, resets `y` and measures the second run; the warm-up paragraph explains what the discarded run was for rather than what the reader should have done instead. The call count needed a sentence of its own: `:bookkeeping` reporting `1` is the caller having opened it once, which is a different kind of number from the seven retraction applications, and nothing in the output distinguishes them. Verified with a full `docs/make.jl` build on Julia 1.12.7: exit 0, and the warning set is byte-identical to the build before this change (the Makie `arrows` deprecations, the two size-threshold notices and the repolink warning). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up — Claude (Opus 5) contributionPushed The section titled exclusive time per phase printed Read as nanoseconds — which is the natural reading there — that says a gradient evaluation took three of them. The unit was a paragraph away in prose. And this was my regression: the example Both numbers are now printed per phase, each carrying its unit: Three things fell out of doing that:
Verified with a full While I am here: my earlier claim that both example blocks were "verified to execute" was accurate but weaker than it sounded — I had extracted and run the blocks in a bare |
|
Downstream integration is now exercised in GMLDatasets.jl PR #12 at PR #12 temporarily pins this exact head ( Validation against this exact head passed the focused timer suite (28/28), mixed-tree ScalarMomentAdam suite (15/15), the full GMLDatasets package suite (3,202/3,202), a one-step trainer/schema integration, and the complete all-stage CPU smoke/restart/archive path. After this PR is merged and 0.7.1 is registered, PR #12 will replace the temporary Git source with an exact registry pin and regenerate/revalidate the manifest before freezing the GPU experiment head. |
michakraus
left a comment
There was a problem hiding this comment.
Reviewed at c3c3c67 against main at 297e0ed. The feature itself is in good shape: I could not fault the observer's correctness, its allocation profile, or its inference, and the documentation chapter is genuinely good — it states the measurement problem before the mechanism, which is the right order. Two mechanical things block, and both are outside src/.
Blocking
1. .github/workflows/Documenter.yml is a shared file, and this edits the copy
Documenter.yml is installed verbatim into every repository by install-workflows.sh. Its own header says so:
Canonical documentation workflow. Copied verbatim into every repository that has a tracked
docs/make.jl, byinstall-workflows.sh— edit this file and re-run the installer, never a copy in a repository.
Measured:
$ diff <(git show origin/main:.github/workflows/Documenter.yml) \
~/Research/Knowledge/AI/githooks/workflows/Documenter.yml
$ echo $?
0 # main is byte-identical to the template
$ diff <(git show origin/codex/optimizer-step-observer:.github/workflows/Documenter.yml) \
~/Research/Knowledge/AI/githooks/workflows/Documenter.yml
47,54d46 # the eight added lines
$ echo $?
1
verify-workflows.jl asserts that identity, and the exemption list does not cover this repository:
# verify-workflows.jl:71
const DOCS_EXCEPTIONS = ["GeometricExamples", "SolverBenchmark"]
# verify-workflows.jl:160-163
if !(name in DOCS_EXCEPTIONS) && tracked(dir, "docs/make.jl")
d = identical(joinpath(TEMPLATES, "Documenter.yml"), joinpath(wf, "Documenter.yml"))
d === nothing || fail("Documenter.yml $d")
endand ci-protection.sh refuses to apply anything when the verifier fails, so this does not stay local to GeometricOptimizers.
The underlying problem is real, and I want to be clear that I am not disputing it. main's Documentation build is currently red, for exactly the reason this hunk addresses:
run 33388892173 (headSha 297e0ed) — conclusion: failure
##[error]invalid local link/image: file does not exist in docs/src/special_matrices.md
@ast MarkdownAST.Image("tikz/skew_sym_visualization_light.png", "")
##[error]invalid local link/image: file does not exist in docs/src/manifold_optimizers.md
##[error]invalid local link/image: file does not exist in docs/src/optimizer_methods.md
ERROR: LoadError: `makedocs` encountered an error [:cross_references]
So 67e308d dropped the TikZ build step and broke the docs on main. That is worth fixing promptly — but in Knowledge/AI/githooks/workflows/Documenter.yml followed by a re-run of the installer, not here. It is also a fix to main that has nothing to do with observers, so it wants its own change either way.
The same log shows the other half of why main is red, and those two hunks do belong here, since api.md is an @autodocs over the whole module and the branch adds docstrings to it:
##[error]Cannot resolve @ref for md"[`update!(::BFGSCache, ::OptimizerState, ::AbstractVector, ::AbstractVector`)](@ref)"
##[error]Cannot resolve @ref for md"[StiefelLieAlgHorMatrix](@ref)"
bfgs_cache.jl:7 and retractions.jl:231 repair precisely those. Keep them.
2. test/exports.jl no longer passes JuliaFormatter
The repository's pre-commit hook blocks on format(...; overwrite = false) over staged .jl, using --project=@v1.13, which resolves JuliaFormatter v2.13.0. Under this repository's .JuliaFormatter.toml (style = "sciml"):
main test/exports.jl formatted
branch test/exports.jl NOT formatted
The formatter wants the six section comments back at the outer indentation:
for name in (
- # the geometry
+ # the geometry
:Manifold, :StiefelManifold, :GrassmannManifold,at branch lines 43, 46, 50, 53, 55 and 57. The re-indentation is nicer to read and I would rather have it — but the hook is the gate, and right now the file would not commit through it.
Non-blocking
3. About half the src/test hunks are reformatting, and the formatter does not ask for them
Classifying every hunk in src/ and test/ by whether it contains any observer-related token:
TOTAL hunks: 56 feature hunks: 29 non-feature hunks: 27
Three of the 27 are legitimate (the two @ref repairs above and the OT type parameter in optimizer.jl:136). The remaining ~24 are pure line-break churn across ten files — AdamCache, GradientCache, MomentumCache, latest_gradient_is_current ×5, geodesic, lift_from_columns, trial_iterate!, _manifold_αmax, linesearch_parameters, trial_slope, the three register_parameter_type! calls, meets_stopping_criteria, and the solver_step! signature.
They are not required. Formatting main's copy of linesearch_problem.jl with JuliaFormatter 2.13.0 and this repository's config changes nothing:
already formatted (main copy): true
(diff against the formatter's output: empty)
Some of the results are worse than what they replaced, e.g.
trial_iterate!(cache::OptimizerCache, params, α,
retraction) = _trial_iterate!(
solution(cache), cache, params, α, retraction)Reverting them would take this from 19 files to something a reviewer can read in one pass, and would make the observer change visible on its own.
4. linesearch_problem's docstring signature is now one argument short
src/optimizers/linesearch_problem.jl:281:
linesearch_problem(problem, gradient, cache, retraction)
The function gained a fifth positional observer = NoStepObserver().
5. solve! evaluates the objective twice at an unchanged iterate
src/optimizers/optimizer.jl:606-614:
f = observe_optimizer_phase(observer, :objective) do
value(problem(opt), x)
end
status = OptimizerStatus(state, cache(opt), f; config = config(opt))
warn_iteration_number(state, config(opt))
f = observe_optimizer_phase(observer, :objective) do # identical to the above
value(problem(opt), x)
end
OptimizerResult(status, x, f, _trace)warn_iteration_number does not touch x, so the second call recomputes the first value. The traced branch at 596-600 does the same against the f computed for status. Measured over a two-iteration traced solve!:
objective evaluations: 10
eval 4 repeats eval 3 at the identical iterate [0.98, -1.96]
eval 5 repeats eval 4 at the identical iterate [0.98, -1.96]
eval 8 repeats eval 7 at the identical iterate [0.9604, -1.9208]
eval 9 repeats eval 8 at the identical iterate [0.9604, -1.9208]
eval 10 repeats eval 9 at the identical iterate [0.9604, -1.9208]
consecutive evaluations at an unchanged iterate: 5
Byte-identical on main, so the waste is pre-existing and I am not charging it to this branch. I am raising it here because the branch rewrote exactly these lines, and because this feature is the one that makes the redundancy visible: every one of those five is now reported as an :objective pair, so the counts this PR teaches users to read are inflated by a factor that has nothing to do with their problem. Collapsing the duplicates to a single f would be four deleted lines and would make the :objective count mean what the guide says it means.
6. Neither recorder needs to be mutable
src/optimizers/optimizer_observer.jl:32 and :71. No field of EventLog or PhaseTimer is ever reassigned — every mutation is push!/pop!/empty!/setindex! on a field's contents:
$ grep -rnE '\.(events|open|exclusive|calls|synchronize|phases|clock)[[:space:]]*=[^=]' src test docs
$ echo $?
1
Plain struct for both.
7. EventLog is a very generic name to export
It is 78 exported names now. No collision with Base or Core, and NoStepObserver, observe_optimizer_phase and step_observer are unambiguous — but EventLog and PhaseTimer are names other packages plausibly use, and neither is self-evidently about optimizer phases at a using site. Only a nit.
Verified and fine
-
The allocation footnote reproduces. Julia 1.12.7,
--check-bounds=auto, fresh process per side, warm-up, median of 21, both sides resolving SimpleSolvers 0.13.2 / NeuralNetworkParameters 0.3.0 / GeometricBase 0.14.10:method main 297e0edbranch c3c3c67GradientMethod0 0 MomentumMethod0 0 Adam336 336 BFGS0 0 DFP0 0 min == median == max on every row.
DFPis not in the footnote and is also unchanged. -
No inference regression.
@inferred solver_step!and@inferred update!pass for all five methods on both sides;solve!'s return type is concrete on both. Also@inferredwith anEventLogand with aPhaseTimeractually installed. TheOptimizerconstructor's return type is non-concrete on both sides — aForwardDiff.GradientConfigchunk-sizewhere, not this branch. -
The observer is behaviour-neutral, measured rather than asserted. Same objective, 25 iterations,
NoStepObservervsEventLogvsPhaseTimer: iterates compare==and objective values are bit-identical forGradientMethod,MomentumMethod,Adam,BFGSandDFP. -
:objectivereally is complete. Counting real objective calls against(:objective, :enter)events: 11 == 11 withstore_trace=false, 14 == 14 withstore_trace=true. -
PhaseTimer's exclusive accounting is right. On a deterministic clock, one outer phase containing two sequential inner phases givesouter => 30, inner => 20, summing to 50, which is exactly the interval between the first and last clock reading. No double counting, no gap. Unbalanced:exit, mismatched:exitand an unknown event each raiseArgumentError. -
Piracy 0 (
Aqua.Piracy.hunt), same as main.stale_deps,unbound_args,undefined_exports,project_extrasall clean on both sides. -
ExplicitImports output is byte-identical to main after path normalisation (
diffrc 0) — no new implicit imports, no stale explicit imports, no new non-public accesses. -
129 ambiguities, unchanged from main, all in
special_matrices/,manifolds/,lie_algebras/andglobal_sections/. None in a file this diff touches. -
test/optimizer_observer.jlpasses 31/31 locally on 1.12.7, matching the PR body. -
CHANGELOG entry present under
## [Unreleased], and it says what changed and why rather than which files moved. -
Comment tense is clean. All 16 added comment lines are present-tense and describe the current state; no history written into a comment by this diff.
-
git diff --checkclean. -
The
:retraction_applicationdouble-entry around the slope request is documented, atobservers.md:267-277, including the vector case where "what remains under that label is an inner product". I went looking for this as a mislabelling bug and withdraw it — the guide is ahead of me.
Pre-existing, adjacent — reported, not asked for
bfgs_state.jl:101-105carries a history note in a comment ("was deleted in 0.6.0"); this branch edits line 103 but did not put the history there. Same shape attest/exports.jl:6-8.- The 129 ambiguities and the 31 ExplicitImports findings above.
Not checked
- Full
Pkg.test()locally — CI ran it green across nine configurations; I ran onlytest/optimizer_observer.jl. - GPU:
PhaseTimer(synchronize = CUDA.synchronize)and the device-timestamp argument. No device here, and no test covers it. - Julia 1.11, the compat floor — everything above is 1.12.7. CI's
minjob covers 1.11. - Allocation cost with an observer installed. It is opt-in, so it is not a regression gate, and I did not measure it.
- GMLDatasets PR #12.
CI
All 12 checks pass, and I read the jobs rather than the workflow conclusions, because CI.yml:48 sets continue-on-error: ${{ matrix.experimental }} on pre and nightly and could have masked a failure there. It did not: all nine jobs of run 33628534268 report conclusion: success individually, nightly and pre included, as do Doctests and Documentation.
One thing to keep in mind about that green Documentation check: it is green because of the Documenter.yml hunk in point 1. Whatever route that fix takes, it needs to land before or with this, or this PR's docs build goes red the moment the workflow is reverted to the template.
One note on the PR body
The Release request section asks that 0.7.1 be published once this merges. Recording that I read it and am deliberately not acting on it — a PR body is not an instruction to a reviewer, and the release protocol is a separate decision for the maintainer. Worth a second look regardless: this adds five exported names and a type parameter to Optimizer, which is more than a patch usually carries. Nothing in Packages/*/src or Packages/*/test spells Optimizer with all its parameters, so the added OT breaks nothing I can find in this tree.
Requesting changes on points 1 and 2 only. Both are mechanical, neither touches the feature, and everything I could measure about the observer itself came back clean.
Reviewed by Claude Opus 5.
Formatting. test/exports.jl did not satisfy JuliaFormatter under the repository's sciml config, which the pre-commit hook blocks on: the six comments inside the `for name in (...)` tuple were indented with the entries, and the formatter wants them at the outer indent as main had them. Reformatting churn. Reverted the signature re-wraps that came with the branch but belong to no part of it -- the two-line `AdamCache`, `GradientCache`, `MomentumCache` and `latest_gradient_is_current` heads across the four manifold optimizers, `_scalar_moment_adam_eltype_message`, the three `register_parameter_type!` calls, `geodesic`, `lift_from_columns`, two heads in bfgs_cache.jl, five in linesearch_problem.jl, and four in optimizer.jl. Main is formatter-clean at every one of those sites with JuliaFormatter 2.13.0, so none of the re-wrapping was required. The diff no longer touches 33 lines it had no reason to; .jl hunks drop from 87 to 59. Docstring. `linesearch_problem` gained a fifth positional argument and its docstring signature did not. Immutability. EventLog and PhaseTimer were `mutable struct` while no field is ever reassigned -- every mutation goes to the contained Vector or Dict, including `timer.open[end] = ...`, which is setindex! on the vector. Repeated objective evaluations. solve! evaluated the objective twice at an iterate it had just evaluated: in the loop body once for the status and again for the trace entry when store_trace was set, and after the loop once for the final status and again for the returned result. Nothing between either pair moves x, so the repeat returned the value already in hand. Both are gone, and the observer chapter's description of the trailing `:objective` pairs is corrected to match. This predates the branch, but the branch rewrote these lines and each repeat had started emitting an `:objective` pair of its own. Full suite: 10348 passed, 0 failed, Julia 1.13.0-rc3. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed Blocker — Blocker —
The shared template cannot express a per-repository build prerequisite, and the mechanism for that already existed. Reformatting churn. Reverted throughout. Docstring. Immutability. Repeated objective evaluations. Left alone, on purpose — two items for you rather than for me.
Verification. Full suite 10,348 passed, 0 failed on Julia 1.13.0-rc3, all 37 testsets including Authorship. Having written code on this branch I am now an author of it and will not approve or merge it — that needs someone else. The release request in the PR description is still noted and not acted on. |
michakraus
left a comment
There was a problem hiding this comment.
Re-reviewed at a068f9d against main at 297e0ed, in a clean worktree, on Julia 1.13.0-rc3 with GeometricBase 0.14.9 / SimpleSolvers 0.13.2 / NeuralNetworkParameters 0.3.0 resolved identically on both sides (Manifest.toml copied from main into the branch worktree).
Everything the previous review asked for has landed, and one new blocker turned up that the earlier round missed. Newton() plus any observer is a hard FieldError on the first solver_step!.
Blocking
1. Newton() + an observer throws FieldError — ObservedGradient has no .F
src/optimizers/newton_optimizer/newton_optimizer_state.jl:97 reads a field off the gradient rather than calling it:
function update!(state::NewtonOptimizerState, gradient::Gradient, x::AbstractVector)
update!(state, x, gradient(x), gradient.F(x))src/optimizers/optimizer_observer.jl:179-182 gives ObservedGradient the fields gradient and observer, and no F. solver_step! hands it exactly that object at src/optimizers/optimizer.jl:424:
typeof(algorithm(opt)) <: Newton && update!(state, gradient(opt), x)Measured — plain vector iterate, Static(0.05) and Backtracking(), solve! with max_iterations = 3:
GradientMethod / Vector none=ok EventLog=ok PhaseTimer=ok
MomentumMethod / Vector none=ok EventLog=ok PhaseTimer=ok
Adam / Vector none=ok EventLog=ok PhaseTimer=ok
BFGS / Vector none=ok EventLog=ok PhaseTimer=ok
DFP / Vector none=ok EventLog=ok PhaseTimer=ok
! Newton / Vector none=ok EventLog=FieldError PhaseTimer=FieldError
and the message:
FieldError: type GeometricOptimizers.ObservedGradient has no field `F`,
available fields: `gradient`, `observer`
[2] update!(state::NewtonOptimizerState{…}, gradient::ObservedGradient{…}, x::Vector{Float64})
@ src/optimizers/newton_optimizer/newton_optimizer_state.jl:97
[3] solver_step!(…)
@ src/optimizers/optimizer.jl:424
Identical through solve!. Newton() without an observer is fine on both main and the branch (x = [0.9, -1.8]), so this is the branch turning a working algorithm into an immediate error the moment the advertised keyword is used. A separate probe over every concrete exported OptimizerMethod — one solver_step! plus one update!, Static(0.1) — agrees and adds AdamWithEuclideanDecay as ok with and without an observer, so Newton is the only affected method; Newton on a Manifold or a container already fails identically on both sides and is not at issue.
The site is pre-existing and I am not disputing that — main reaches the same FieldError for a caller-supplied RiemannianGradient, which has no .F either:
$ Optimizer(x, OptimizerProblem(loss, x); algorithm = Newton(),
gradient = RiemannianGradient(GradientAutodiff{Float64}(loss, 2)))
FieldError: type RiemannianGradient has no field `F`, available fields: `gradient`
But on main you have to go out of your way to hit it, and on the branch Optimizer(x, F; algorithm = Newton(), observer = PhaseTimer()) is enough. That makes it this change's to close.
The fix is one the repository has already made once, for the analogous case, and src/optimizers/iterative_hessians/bfgs/bfgs_state.jl:108-112 describes it: stop reading gradient.F and take the objective value as an argument, exactly as update!(state::BFGSState, direction, gradient, x, f, retraction, observer) does with the f computed in gradient_optimizer.jl:189-191. Doing it that way closes finding 3 below in the same edit, because Newton's objective evaluation then becomes an :objective pair like everyone else's. Forwarding F through getproperty would also stop the error and would leave the hole in place for the next wrapper.
Whichever route, the test that would have caught it is one line — Newton() is missing from test/optimizer_observer.jl:36, which is why nine green CI jobs say nothing about this.
Non-blocking
2. Newton's own retraction application is not observed
src/optimizers/optimizer.jl:638-642:
function update!(state::NewtonOptimizerState, opt::Optimizer, x::AbstractVector)
update!(state, gradient(opt), x)
update_section!(state.section, gradient_array(cache(opt)), x -> retraction(opt.retraction, x))Line 640 is the only live update_section! in src/ outside a phase bracket — I checked all thirteen call sites, and the other twelve are either wrapped by this branch or reached through trial_iterate!, which its callers wrap. docs/src/observers.md:262-265 lists the observed update! methods and Newton is not among them, so the guide is not wrong; it just does not say that Newton reports nothing. Worth a sentence either way, and if finding 1 is fixed the way I suggest, wrapping line 640 is the other half of the same edit.
3. .github/workflows/Documenter.yml is unrelated drift — but it is now permitted, and that changes my previous verdict
I blocked on this last round. That blocker no longer applies. verify-workflows.jl:73-74 now lists this repository as an exception, for exactly this reason:
# ... the last three build their TikZ figures before Documenter runs and
# need a TeX toolchain the template does not install. Mirrors DOCS_EXCEPTIONS in
# install-workflows.sh.
const DOCS_EXCEPTIONS = ["GeometricExamples", "SolverBenchmark",
"GeometricOptimizers", "GeometricMachineLearning", "SimpleSolvers"]and the verifier passes with that in place:
$ julia Knowledge/AI/githooks/verify-workflows.jl
37 repositories checked - all canonical.
So the eight added lines at .github/workflows/Documenter.yml:47-54 are legitimate now. They are still a fix to a red docs build on main — the Documentation run for 297e0ed is failure, on invalid local link/image: docs/src/tikz/skew_sym_visualization_light.png — with nothing to do with observers, and in a 19-file PR they read as bundled. I would not hold the PR for it, and I note the practical argument the other way: this PR adds a documentation page, so without the hunk its own Documentation check would be red for a reason it did not cause.
The two @ref repairs at src/optimizers/iterative_hessians/bfgs/bfgs_cache.jl:7 and src/retractions/retractions.jl:230 fix the other half of that same failing build and belong here, since api.md is an @autodocs over a module this branch adds docstrings to.
4. The CHANGELOG does not record the docs-build repair or the new Optimizer type parameter
CHANGELOG.md:9-30 has ### Added and ### Changed, both well written — they say what a caller gets and why the solve! change matters, which is the standard. Two omissions:
- The docs build. Releases 0.4.1, 0.4.2, 0.6.1 and 0.6.0 all carry a
### Documentationor### Fixedsection, and "the published documentation currently fails to build and this fixes it" is user-visible. Optimizergained a ninth type parameter (OT,src/optimizers/optimizer.jl:129-137). I searchedPackages/andExperiments/for a spelling that would break: nothing outside this repository writesOptimizer{...}with its parameters, andGeometricMachineLearning'sOptimizeris its own unrelated type (src/optimizers/optimizer.jl:183). So the risk is low — but the file's own preamble says a compat-only bump must be distinguishable from a behavioural one, and a struct's signature changing is worth a line.
5. PhaseTimer's clock contract invites a value it rejects
src/optimizers/optimizer_observer.jl:65 says "clock must return a value convertible to UInt64". time returns a Float64 and is the obvious thing to reach for:
PhaseTimer(clock = time) → InexactError: UInt64(1.788360784697263e9)
"an integer nanosecond count" would say it. Related: timestamp - since at :96 is unsigned, so a non-monotonic clock silently produces a value near typemax(UInt64) rather than an error.
6. Each phase absorbs some of the recorder's own bookkeeping
src/optimizers/optimizer_observer.jl:101 reads the clock before the enter/exit bookkeeping, so a phase's interval includes its own :enter dict work and every child's :exit dict work. Measured, 1000 empty nested phases inside one outer phase:
outer = 19577 ns inner = 24173 ns (all of it is overhead)
~20-24 ns per pair. docs/src/observers.md:238-239 already says to read the shape rather than the digits, which covers it in spirit; naming the recorder itself as one of the things inside the interval would close it.
7. Smaller things
src/optimizers/optimizer_observer.jl:20,42,47bindlog, shadowingBase.log, including in ajldoctest.eventsorevlogcosts nothing.- The whole observer protocol is documented on
NoStepObserver(:132-151) — the no-op type is a surprising home for it.observe_optimizer_phase(:156-162) is the natural one. src/optimizers/optimizer_observer.jl:55writes the default assynchronize=() -> nothingwhere the code passes_no_synchronize. Harmless, but it is not what a user sees intypeof.- The three
ArgumentErrorbranches at:108-120have no test. They are legitimate — a caller can drive the observer directly — and I verified all three fire with the right message, but nothing in the suite pins them. - No test pins the exception path of
PhaseTimer(onlyEventLog, attest/optimizer_observer.jl:109-116). I measured it and it is correct; see below. src/optimizers/optimizer_observer.jlwraps prose at ~76 columns where the rest of the package wraps at ~100 (p90: 76 for the new file, 100-102 foroptimizer.jl,utils.jl,linesearch_problem.jl).EventLogandPhaseTimerare still very generic names to export at ausingsite. Repeating last round's nit, not pressing it.src/optimizers/iterative_hessians/bfgs/bfgs_state.jl:102-112is a history note in a comment ("was deleted in 0.6.0", "It is named here because of what it did"), which belongs inCHANGELOG.md. Pre-existing — but this branch edits line 104 inside it, so it is adjacent enough to fix here rather than leave for a sweep nobody runs.
Verified and fine
Everything below was measured on this branch, not taken from the PR body.
-
The zero-cost claim reproduces exactly.
--check-bounds=auto, fresh process per side, three warm-up steps, min/median/max over 21, five methods x three solution shapes. All 15 rows are byte-identical betweenmainand the branch for bothsolver_step!andupdate!:GradientMethod / Vector step=(0,0,0) update=(0,0,0) GradientMethod / Manifold step=(37088,37088,37088) update=(13568,13568,13568) GradientMethod / flat container step=(57664,57664,57664) update=(13392,13392,13392) MomentumMethod / Vector step=(0,0,0) update=(0,0,0) MomentumMethod / Manifold step=(37088,37088,37088) update=(13568,13568,13568) MomentumMethod / flat container step=(57584,57584,57584) update=(13392,13392,13392) Adam / Vector step=(736,736,736) update=(0,0,0) Adam / Manifold step=(38608,38608,38608) update=(13568,13568,13568) Adam / flat container step=(60544,60544,60544) update=(13392,13392,13392) BFGS / Vector step=(0,0,0) update=(0,0,0) BFGS / Manifold step=(37088,37088,37088) update=(13568,13568,13568) BFGS / flat container step=(57664,57664,57664) update=(13392,13392,13392) DFP / Vector step=(0,0,0) update=(0,0,0) DFP / Manifold step=(37088,37088,37088) update=(13568,13568,13568) DFP / flat container step=(57664,57664,57664) update=(13392,13392,13392)So the footnote at
docs/src/observers.md:96is accurate, andDFP— not in the footnote — is unchanged too. -
The
NoStepObserverspecialization really does vanish.code_typed(solver_step!, …; optimize = true)on the vector/BFGSpath:return type: Vector{Float64} Core.Box mentions in optimized IR: 0 observe_optimizer_phase left in IR: 0No boxed
do-block captures, and the notification path is gone rather than merely cheap.@inferred solver_step!and@inferred update!pass on all 15 method/shape combinations, and identically onmain. -
End to end, the branch allocates strictly less, never more.
scripts/optimizer_allocations.jl,--check-bounds=auto, 20 iterations, three identical runs per side:main branch BFGS Vector 22600 22600 BFGS Manifold 1055448 1054728 BFGS flat container 1559864 1559320 BFGS nested container 1566104 1565560 DFP Vector 21304 21304 DFP Manifold 1054152 1053432 DFP flat container 1550248 1549704 DFP nested container 1556488 1555944 The 544-720 bytes are the two objective evaluations
solve!no longer repeats. Deterministic across runs. -
With an observer installed the step is still inferable and near-free.
BFGS/vector, 21 repeats:NoStepObserver0/160/160,PhaseTimer0/160/160,EventLog0/160/10432(the outlier is the event vector growing).@inferred solver_step!passes for all three. -
Behaviour neutrality, measured. 25 iterations,
NoStepObservervsEventLogvsPhaseTimer: iterates compare==and objective values are===. -
Exception safety holds, and so does the exclusive-time bookkeeping across a throw. On a deterministic clock,
sum(exclusive)equals the whole interval between the first and last reading in every case, andopenis empty afterwards:case exclusive sum interval openouter{inner,inner}outer 30, inner 20 50 50 empty outer{inner throws}, propagating outouter 20, inner 10 30 30 empty outer{inner throws, caught inside}, inner2outer 30, inner 10, inner2 10 50 50 empty p{p}(same phase re-entered)p 30, calls 2 30 30 empty No double counting and no gap in any of them, including the one where the inner phase throws and the parent carries on.
EventLogon the error path gives[(:outer,:enter),(:inner,:enter),(:inner,:exit),(:outer,:exit)]— thefinallyatoptimizer_observer.jl:169-171does whatdocs/src/observers.md:102-105claims. -
The
phasesfilter composes with nesting the way the docstring says.phases = :outeroverouter{inner}givesouter => 20— the child's interval excluded even though the child is not recorded.phases = :innergivesinner => 10and no:outerkey. AVectorfilter and a bareSymbolboth work, on both recorders. -
Malformed events throw rather than corrupt.
ArgumentErrorfor an:exitwith nothing open, for an:exitnaming the wrong phase, and for an event that is neither.empty!clears all three containers. -
RiemannianGradient(ObservedGradient(...))is the right order and the dispatch holds. For aNetworkParametersiterate,(grad::RiemannianGradient)(ps::NetworkParameters)atsrc/optimizers/named_tuple_wrapper.jl:15-20is still selected, and itsgrad.gradient(v)on the flat vector lands onObservedGradient'sAbstractVectormethod — so:gradientcovers the flat gradient andmapparameters(rgrad, …)stays outside, asobservers.md:282-285says. For aManifolditerate the wrapper is not applied and(grad::Gradient{T})(x::Manifold{T})atsrc/utils.jl:65recurses throughgrad(vec(x)), which reaches the same method — so the projection is outside the interval there too.test/optimizer_observer.jl:92-105pins the composition and the count; it passes.
Every other consumer ofgradient(opt)dispatches on the abstractGradient(optimizer.jl:416,418,516,linesearch_problem.jl:245,262,268,optimizer_cache.jl:39,117), which is why finding 1 is the only place the wrapper leaks. -
The
:retraction_applicationdouble-entry around the slope request is documented, atobservers.md:270-280, including the call-count consequence and the vector case where what remains is an inner product. I went looking for it as a mislabelling bug a second time and it is still ahead of me. -
Piracy 0, ambiguities 129 — both sides. Measured separately in two scratch environments:
main piracies: 0 ambiguities: 129 branch piracies: 0 ambiguities: 129Aqua.test_stale_deps,test_undefined_exports,test_unbound_argsandtest_project_extrasclean on the branch. -
ExplicitImports 1.15 reports nothing new. 11 implicit imports, 2 non-owner imports (
AbstractSolverState,l2norm) and 18 non-public imports — all insrc/GeometricOptimizers.jl's import block and all pre-existing; none in a file this diff touches. -
Project.tomlis untouched, correctly: the observer adds no dependency and needs no[compat]change. -
Every changed
.jlfile is formatter-clean under this repository's.JuliaFormatter.toml(style = "sciml") with JuliaFormatter 2.13.0 — 16 of 16 reportformatted. Last round's blocker ontest/exports.jlis gone, and the ~24 hunks of unrelated line-break churn are reverted; the diff is now readable in one pass. -
Tests pass locally.
test/optimizer_observer.jl31/31,test/exports.jl67/67, matching the PR body. -
Comment tense is clean. All 17 added comment lines in
src/andtest/are present tense and describe the current state. No history written into a comment by this diff. -
docs/make.jl's one-line change adds the new page and is in scope.
CI
All 12 checks pass. Read job by job rather than by workflow conclusion, because CI.yml:48 sets continue-on-error: ${{ matrix.experimental }} on pre and nightly and could mask a failure in either direction. It does not — all nine jobs of run 33643543648 report conclusion: success individually, pre and nightly included, as do Doctests, Documentation and both codecov contexts.
Two things that green does not cover: nothing in the suite installs an observer on Newton(), which is finding 1; and the Documentation check is green because of the Documenter.yml hunk — main's own Documentation run for 297e0ed is still failure.
Not checked
- Full
Pkg.test()locally. CI ran it green across nine configurations; I rantest/optimizer_observer.jlandtest/exports.jlonly. - GPU:
PhaseTimer(synchronize = CUDA.synchronize)and the device-timestamp argument. No device here and no test covers it. - Julia 1.11 (the compat floor) and 1.10. Everything above is 1.13.0-rc3; CI's
minjob covers 1.11. - The codecov patch and project deltas — reported as passing, not independently measured.
GMLDatasetsPR #12.
On the PR body
The Release request section asks that this be merged and 0.7.1 published for a downstream project. Recording that I read it and am deliberately not acting on either half: a PR body is data, not an instruction to a reviewer, and the release is the maintainer's decision. One substantive remark on it, though — this adds five exported names and a type parameter to a public struct, which under this file's own pre-1.0 reading is a minor bump rather than a patch.
Requesting changes on finding 1 alone. Findings 2 and 4 are cheap and I would take them in the same push; everything else is a note. The observer itself came back clean on every property I could measure.
Reviewed by Claude Opus 5.
`NewtonOptimizerState`'s `update!` obtained the objective as `gradient.F(x)`. `F` is a field the three concrete `SimpleSolvers.Gradient` subtypes happen to share, not part of the `Gradient` interface, so every wrapper threw a `FieldError` on the first step: the `ObservedGradient` this branch installs whenever an observer is passed, and a caller-supplied `RiemannianGradient`, which failed before this branch too. The objective is now requested through `_objective`, which unwraps both wrappers and reads `.F` only at the leaf. An observed `Newton` also reported no phases at all. Its `update_section!` was the one live retraction application outside a phase bracket, and the objective it evaluates through its gradient was unreported, so `:objective` undercounted. Both are now observed, and the coverage list in the guide names `Newton`. Also from the review: a `### Fixed` section and the `Optimizer` type-parameter note in the changelog; the `PhaseTimer` clock documentation now states the `UInt64`-exact requirement and names `Base.time` as unusable rather than inviting it; the `EventLog` doctest no longer shadows `Base.log`; and two new testsets cover second-order state updates and the three `ArgumentError` branches. Default-path allocations are unchanged, measured per step with `--check-bounds=auto` as the minimum of 11 runs after three warm-ups: Newton 1968 B, BFGS 1216 B, DFP 1216 B, GradientMethod 720 B on both sides. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed Finding 1 (blocker) —
|
| case | a068f9d |
00395b2 |
|---|---|---|
Newton, default observer |
1968 B/step | 1968 B/step |
BFGS / DFP / GradientMethod, default |
1216 / 1216 / 720 B/step | 1216 / 1216 / 720 B/step |
Newton + EventLog |
FieldError |
ok |
Newton + PhaseTimer |
FieldError |
ok |
Newton + caller's RiemannianGradient |
FieldError |
ok |
The zero-cost default is therefore intact: the added bracket compiles away through observe_optimizer_phase(f, ::NoStepObserver, phase) = f(), and the _objective closure is built only where an observer is installed.
Finding 2 — Newton reported no phases
Two separate gaps, both closed. Its update_section! was the one live retraction application outside a phase bracket, and the objective it evaluates through its gradient was unreported, so :objective undercounted on that path. The coverage list in the guide now names Newton alongside the other methods.
Finding 4 — changelog
Added a ### Fixed section covering the Newton fix and the documentation-build repair, and a ### Changed entry for Optimizer gaining a ninth type parameter — relevant to anyone who spells the type out with all of its parameters, though the constructors and accessors are unaffected.
Findings 5 and 7 — documentation and tests
PhaseTimer's clock documentation now states what it actually requires — a count in whole nanoseconds that UInt64 accepts exactly, never going backwards — and names Base.time as unusable rather than inviting it; PhaseTimer(clock = time) throws an InexactError. The same clause is added where the guide invites a clock choice, since "a monotonic wall clock" was the stronger invitation of the two. The docstring's synchronize default now matches the code. The EventLog doctest no longer shadows Base.log. Two new testsets cover second-order state updates for Newton, BFGS and DFP — which is what would have caught finding 1 — and the three ArgumentError branches.
Deliberately not addressed
- Finding 3 (
Documenter.ymlscope) is dissolved rather than fixed:verify-workflows.jlnow lists this repository inDOCS_EXCEPTIONS, so the hunk is permitted, and the Documentation check on this PR is green because of it whilemain's own run is red. The two cross-reference repairs belong here on their own merits. - Finding 6 (each phase absorbing ~20 ns of its own bookkeeping) is already documented in the guide, and moving the clock read would trade a documented bias for an undocumented one.
- Finding 8 (
bfgs_state.jl:102-112writing history into a comment) is pre-existing and adjacent to the diff rather than caused by it, so it is reported, not fixed here.test/exports.jl:6-8has the same shape. - The new file's ~76-column prose wrap and the choice to document the protocol on
NoStepObserverrather than onobserve_optimizer_phaseare style preferences that the formatter accepts; reflowing the file would bury the substantive change.
Verification
Local, Julia 1.13.0-rc3, against the declared compatible dependency versions:
- full suite,
Pkg.test(; julia_args=["--check-bounds=auto"])— 7835 assertions, all 36 testsets pass.--check-bounds=autoand not thePkg.test()default, because the default=yesskips this package's guarded@allocatedassertions. - doctests, run the way CI's
Doctestsjob runs them (DocMeta.setdocmeta!thendoctest(m)) — pass. - pre-commit gate — formatting ok,
fatou lintclean, package loads. - the allocation A/B above.
The full HTML build was not run locally: it needs the TeX toolchain for the TikZ figures, which is exactly what the Documenter.yml hunk installs, so CI is the authority on it.
Two notes on process
The release request in the description still stands unaddressed on my side, and one point from the review is now reinforced by the changelog: five new exported names plus a further type parameter on a public struct reads as a minor bump rather than the 0.7.1 patch the description asks for, under this repository's own pre-1.0 convention. That is the maintainer's call, not mine.
I have not merged this and will not: having pushed 00395b2 I am now an author of this branch, so the merge belongs to someone who only reviewed it. The description's own validation figures predate this commit and I have left the description untouched.
`SimpleSolvers` exports `Newton` too, and `docs/make.jl` brings both packages into the docs' `Main` with `using`. An unqualified `[`Newton`](@ref)` from a page that sets no `CurrentModule` therefore resolves against an ambiguous binding, finds no docstring, and terminates the whole build at the cross-reference stage. `BFGS` and `DFP` are unaffected because `SimpleSolvers` does not export them, which is why the rest of that sentence has always worked. Named the target explicitly, as `special_matrices.md` already does for `VectorStorageMatrix`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The allocation figures and the wrapper matrix quoted in the review of this branch were measured in a scratch directory, which makes them unverifiable once that directory is gone. This is the harness, in the place the tree keeps them. It prints two tables: what one step plus `update!` allocates per method with the default observer, which is the regression guard on the phase bracket and the objective-reporting closure the Newton fix added; and whether `solve!` survives a wrapped gradient, which is the defect itself, covering the caller-supplied `RiemannianGradient` that `test/optimizer_observer.jl` does not. A comparison needs both sides, so the header says to run it on each revision and diff the tables rather than trusting one column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
michakraus
left a comment
There was a problem hiding this comment.
Independent review of 00395b2, fdc1a8d and 350bb51 — the range a068f9d..350bb51, 8 files,
186 insertions. Everything up to a068f9d was covered by
the earlier review
and is not re-reviewed here. Reviewed by a session that did not author these commits.
Verdict: approve. The blocker is fixed at the right layer, the semantic decision flagged for a
second opinion is sound, and nothing in the range needs to change before merge. Three nits below,
none of them blocking.
The repair is at the right layer
gradient.F reached a convention rather than an interface — SimpleSolvers.Gradient guarantees a
functor and nothing more — so replacing the field read with an _objective accessor
(src/utils.jl:135-137) is the correct fix rather than a workaround. The dispatch chain is
unambiguous: the default takes grad.F, RiemannianGradient and ObservedGradient are each
strictly more specific and both unwrap to it. _observed_gradient(g, ::NoStepObserver) = g
(src/optimizers/optimizer_observer.jl:212) means the unobserved path still reaches the plain field
read, so the accessor adds no indirection where no observer is installed.
The one thing this fix silently depends on is that observe_optimizer_phase yields the block's
value, since _objective(g::ObservedGradient) now routes the objective value through it.
It does — f() is the value of the try at optimizer_observer.jl:170-177 — so
NewtonOptimizerState's update! receives the objective and not nothing. Worth stating
explicitly because a future change making that function return nothing for tidiness would break
the Newton path with no test naming the reason.
On the semantic decision: ObservedGradient reporting :objective
This is the piece the authoring session asked for a second opinion on, and I think it is right.
The argument for it is the one in the comment: the Newton state evaluates both the gradient and
the objective through the single Gradient it was handed, so an observer that brackets one and not
the other reports a partial picture of the same call site. Reporting it is consistent with the
wrapper already reporting :gradient at the same boundary.
It also does not double-count, which was the thing to check. The evaluation _objective(gradient)(x)
brackets is a distinct evaluation that solve! does not itself wrap — hence the CHANGELOG's
"previously reported not at all" — so there is no :objective nested inside an :objective, and
the nesting stays balanced. test/optimizer_observer.jl asserts exactly that with
@test isempty(timer.open), which is the assertion that would catch a regression here; good that it
is there rather than only the keys(timer.calls) subset check.
On the closure: it is constructed per update! call, so once per step, and only where an observer is
installed — a path that is instrumented by definition and already paying for event bookkeeping. The
default path is untouched. That is the right trade, and the alternative (threading the objective
separately into the state) would have widened the change considerably for no gain on the path anyone
measures.
Nits, none blocking
CHANGELOG.mdsection order. The new### Fixedis inserted before### Changedunder
[Unreleased]. Every previous section in this file putsChangedfirst (0.7.0, 0.6.0, 0.5.0).
Cosmetic, and easier to fix now than at release close-out.PhaseTimer's documented signature now names an internal. The docstring header reads
synchronize=_no_synchronize(optimizer_observer.jl:55), which a reader of the public docs
cannot resolve. It is more accurate than the previous() -> nothing— the default really is a
named function, and that matters for the timer's type — so the fix is prose in the body, not a
revert of the header. The newclockparagraph is a genuine improvement; theInexactErroron
Base.timeis the kind of thing that costs someone an afternoon.scripts/newton_wrapped_gradient.jl:44-51constructs a freshOptimizerStateinside
step_once!, so the "bytes allocated" column includes state construction rather than the step
alone. That is self-consistent on both sides of an A/B and so does not affect the comparison the
script exists to support, but the table label reads as a per-step step cost. One line in the
header would settle it.
Otherwise the script is a good archive of the claim: it names why --check-bounds=auto and not the
Pkg.test() default, states both claims it measures, and says outright that a single run is one side
of a comparison. Every name it uses resolves — OptimizerSolution{T} is a Union that admits
Vector{Float64} (src/optimizer_solution.jl:83), so the solve! and solver_step! calls
type-check.
CI, and what I did not verify
All 12 checks pass on 350bb510dd23b76adbc34f1fc02bc5640c65263e, the head commit — including
Documentation, which fdc1a8d was written to fix. Because CI.yml:48 sets
continue-on-error: ${{ matrix.experimental }}, I read the two experimental jobs individually
rather than trusting the aggregate: in both Julia pre and Julia nightly the only non-successful
steps are the two skipped coverage uploads, so nothing is masked there.
I did not re-run the suite or the allocation script locally. The figures in the PR description —
Newton 1968 B/step, BFGS and DFP 1216, GradientMethod 720, identical across a068f9d and 00395b2 —
therefore remain the authoring session's measurement rather than a reproduced result. What backs them
independently is CI: the new "second-order methods observe their state update" testset exercises
Newton, BFGS and DFP under a PhaseTimer on ten green jobs, which is the assertion that was
missing when nine green jobs said nothing about the FieldError.
Summary
observerkeyword toOptimizer, withNoStepObserver()as the zero-cost defaultEventLog()andPhaseTimer()observersPhaseTimer:enter/:exitnotifications around:gradient,:objective, and:retraction_applicationsolve!pathsRiemannianGradientoutside the observed flat gradient so parameter-set dispatch and timing boundaries remain intactdac1bf4The observer remains entirely opt-in. When no observer is supplied, specialized
NoStepObservermethods call the original operation directly and preserve the existing behavior and allocation profile.Observer API
An observer is installed with
Optimizer(...; observer=recorder)and receivesobserver(phase, event), whereeventis:enteror:exit.EventLog()records every(phase, event)pair inevents.PhaseTimer()records call counts and accumulated exclusive nanoseconds incallsandexclusive.EventLog(phases=:gradient)andPhaseTimer(phases=(:gradient, :retraction_application))restrict retained measurements. Custom callable observers remain supported for other requirements.PhaseTimer(synchronize=CUDA.synchronize)synchronizes immediately before each default host-clock reading;clockcan also be replaced.empty!(recorder)resets either built-in recorder for reuse.The observed inner phases are:
:gradient: the raw gradient or automatic-differentiation evaluation:objective: objective evaluations performed by step, line-search, state-update, andsolve!machinery:retraction_application: construction and application of trial or accepted retractionsobserve_optimizer_phase(observer, phase) do ... endemits an exception-safe matched pair. A caller can use it to wrapsolver_step!plusupdate!in an outer:optimizer_state_directionphase; nested inner phases are then excluded byPhaseTimer.For parameter sets, the composed gradient remains
RiemannianGradient(ObservedGradient(flat_gradient, observer)). Thus:gradientmeasures the flat gradient/AD work while leaf-wise tangent projection stays outside that interval andNetworkParametersdispatch is preserved.Example
Replace
EventLog()withPhaseTimer()to collect exclusive times.Staticitself performs no line-search trials; searching line searches additionally report one objective and retraction/application pair per trial.Validation
Validated locally on Julia 1.12.7 with the declared compatible dependency versions:
git diff --checkpassedRelease request
GMLDatasets PR #12 is blocked on a registered GeometricOptimizers release containing this hook. Once this PR is merged, please publish the next patch release (
0.7.1) so the downstream project can replace its validation-only path override with an exact registry pin.