Skip to content

Keep the BFGS inverse Hessian approximation exactly symmetric - #1291

Merged
pkofod merged 4 commits into
masterfrom
dmw/bfgs-symmetric-update
Oct 28, 2026
Merged

pkofod merged 4 commits into
masterfrom
dmw/bfgs-symmetric-update

Conversation

@devmotion

@devmotion devmotion commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

The inverse Hessian update in BFGS has two code paths, and neither keeps invH symmetric in floating point:

  • For invH isa Array, the fused muladd loop rounds invH[i, j] and invH[j, i] differently, so they drift apart by a few ulps after every step. For complex inputs, the diagonal also picks up nonzero imaginary parts.
  • Every other array type takes the mul! branch, which was meant for GPU arrays (# i.e. not a CuArray). This includes CPU arrays such as FixedSizeArray (FixedSizeArrays.jl), whose invH is a FixedSizeMatrix because _init_identity_matrix broadcasts over x. The branch does three separately rounded rank-1 updates. The first, mul!(invH, dx, dx', c1, 1), also changes its implementation depending on that asymmetry: with β = 1, LinearAlgebra calls BLAS.syrk! only if issymmetric(invH) holds bitwise, and BLAS.gemm! otherwise. So Matrix and FixedSizeMatrix inputs follow different arithmetic, and the non-Array path depends on ulp-level noise.

This PR replaces both branches with a single symmetric rank-2 update. It uses c1 * s * s' - c2 * (u * s' + s * u') = s * w' + w * s' with w = c1 / 2 * s - c2 * u, written as one broadcast into invH that reuses the u buffer. Element (i, j) and element (j, i) are computed from the same products in commuted order, so invH stays exactly symmetric (Hermitian for complex inputs). One code path then serves Array, FixedSizeArray, and GPU arrays, without the isa Array check and in a single pass.

On a 16-dimensional SPD quadratic over 200 steps:

update steps with asymmetric invH max one-step relative error vs BigFloat
muladd loop (Array) 200/200 2.1e-16
3× mul! (other arrays, e.g. FixedSizeArray) 200/200 2.6e-16
this PR 0/200 1.0e-16

The update is mathematically unchanged, but rounding differs, so BFGS iterates can change in the last bits.

A test checks that every ~inv(H) in the extended trace is exactly symmetric (real) or Hermitian (complex). It fails on master.

This pull request was written with the assistance of generative AI (Claude Code).

🤖 Generated with Claude Code

Replace the `invH isa Array` loop and the three `mul!` rank-1 updates with a
single symmetric rank-2 broadcast. Both previous code paths left `invH`
slightly asymmetric after every step, and non-`Array` CPU arrays such as
`FixedSizeArray` took the `mul!` path meant for GPU arrays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia v1)

Time benchmarks
master 82d75df... master / 82d75df...
multivariate/problems/noisy_quadratic/8/BFGS/BackTracking 28.3 ± 7.4 μs 22.9 ± 5.3 μs 1.23 ± 0.43
multivariate/problems/noisy_quadratic/8/BFGS/HagerZhang 0.0808 ± 0.0017 ms 0.109 ± 0.0024 ms 0.74 ± 0.023
multivariate/problems/noisy_quadratic/8/ConjugateGradient/BackTracking 10.7 ± 0.53 μs 10.8 ± 0.51 μs 0.991 ± 0.068
multivariate/problems/noisy_quadratic/8/ConjugateGradient/HagerZhang 0.125 ± 0.0029 ms 0.124 ± 0.0033 ms 1.01 ± 0.035
multivariate/problems/noisy_quadratic/8/LBFGS/BackTracking 15.4 ± 3.9 μs 15.8 ± 3.9 μs 0.977 ± 0.34
multivariate/problems/noisy_quadratic/8/LBFGS/HagerZhang 0.146 ± 0.0035 ms 0.145 ± 0.0037 ms 1 ± 0.035
multivariate/problems/rosenbrock/100/BFGS 2.31 ± 0.029 ms 2.51 ± 0.031 ms 0.919 ± 0.016
multivariate/problems/rosenbrock/100/KrylovTrustRegion 0.158 ± 0.011 ms 0.159 ± 0.011 ms 0.994 ± 0.1
multivariate/problems/rosenbrock/100/Newton 1.24 ± 0.022 ms 1.25 ± 0.023 ms 0.997 ± 0.026
multivariate/problems/rosenbrock/100/NewtonTrustRegion 5.99 ± 0.39 ms 5.92 ± 0.4 ms 1.01 ± 0.094
multivariate/problems/rosenbrock/2/BFGS 28.2 ± 4.2 μs 28.8 ± 5.2 μs 0.977 ± 0.23
multivariate/problems/rosenbrock/2/KrylovTrustRegion 21.1 ± 4.1 μs 21.6 ± 5.3 μs 0.975 ± 0.31
multivariate/problems/rosenbrock/2/Newton 26.1 ± 5.3 μs 26.5 ± 5.3 μs 0.986 ± 0.28
multivariate/problems/rosenbrock/2/NewtonTrustRegion 0.0344 ± 0.0081 ms 0.0346 ± 0.0079 ms 0.992 ± 0.33
multivariate/problems/rosenbrock/20/BFGS 0.154 ± 0.011 ms 0.168 ± 0.01 ms 0.917 ± 0.086
multivariate/problems/rosenbrock/20/KrylovTrustRegion 0.0386 ± 0.0022 ms 0.0393 ± 0.0023 ms 0.981 ± 0.08
multivariate/problems/rosenbrock/20/Newton 0.0715 ± 0.001 ms 0.0716 ± 0.0011 ms 0.999 ± 0.021
multivariate/problems/rosenbrock/20/NewtonTrustRegion 0.308 ± 0.016 ms 0.303 ± 0.015 ms 1.02 ± 0.071
multivariate/problems/saddle/100/KrylovTrustRegion 0.0554 ± 0.0071 ms 0.0547 ± 0.0065 ms 1.01 ± 0.18
multivariate/problems/saddle/100/Newton 0.227 ± 0.017 ms 0.227 ± 0.016 ms 1 ± 0.1
multivariate/problems/saddle/100/NewtonTrustRegion 10.1 ± 0.36 ms 10 ± 0.32 ms 1.01 ± 0.048
multivariate/problems/saddle/100/NewtonTrustRegion_noise_floor 2.27 ± 0.076 ms 2.23 ± 0.072 ms 1.02 ± 0.047
multivariate/problems/saddle/2/KrylovTrustRegion 8.32 ± 0.23 μs 8.32 ± 0.24 μs 1 ± 0.04
multivariate/problems/saddle/2/Newton 5.64 ± 0.31 μs 5.66 ± 0.32 μs 0.996 ± 0.079
multivariate/problems/saddle/2/NewtonTrustRegion 12.1 ± 1.2 μs 12 ± 1 μs 1 ± 0.13
multivariate/problems/saddle/2/NewtonTrustRegion_noise_floor 11.9 ± 1 μs 11.7 ± 0.97 μs 1.01 ± 0.12
multivariate/problems/saddle/20/KrylovTrustRegion 12.8 ± 10 μs 12.8 ± 5 μs 0.997 ± 0.88
multivariate/problems/saddle/20/Newton 15.5 ± 0.51 μs 15.4 ± 0.5 μs 1 ± 0.047
multivariate/problems/saddle/20/NewtonTrustRegion 0.109 ± 0.0066 ms 0.109 ± 0.0076 ms 1 ± 0.092
multivariate/problems/saddle/20/NewtonTrustRegion_noise_floor 0.107 ± 0.0078 ms 0.107 ± 0.0073 ms 0.993 ± 0.099
multivariate/solvers/first_order/AdaMax 0.64 ± 0.0094 ms 0.641 ± 0.0094 ms 0.999 ± 0.021
multivariate/solvers/first_order/Adam 0.638 ± 0.0097 ms 0.64 ± 0.0094 ms 0.998 ± 0.021
multivariate/solvers/first_order/BFGS 0.139 ± 0.0045 ms 0.139 ± 0.0028 ms 1.01 ± 0.038
multivariate/solvers/first_order/ConjugateGradient 0.229 ± 0.0094 ms 0.228 ± 0.0072 ms 1 ± 0.052
multivariate/solvers/first_order/GradientDescent 1.01 ± 0.0089 ms 1 ± 0.0085 ms 1.01 ± 0.012
multivariate/solvers/first_order/LBFGS 0.154 ± 0.0025 ms 0.153 ± 0.0028 ms 1 ± 0.024
multivariate/solvers/first_order/MomentumGradientDescent 2.05 ± 0.02 ms 2.02 ± 0.02 ms 1.01 ± 0.014
multivariate/solvers/first_order/NGMRES 1.03 ± 0.0092 ms 1.03 ± 0.0065 ms 1.01 ± 0.011
multivariate/solvers/second_order/KrylovTrustRegion 0.34 ± 0.01 ms 0.339 ± 0.01 ms 1 ± 0.043
multivariate/solvers/second_order/Newton 0.23 ± 0.0092 ms 0.229 ± 0.0078 ms 1 ± 0.053
multivariate/solvers/second_order/NewtonTrustRegion 0.247 ± 0.011 ms 0.247 ± 0.01 ms 1 ± 0.06
time_to_load 0.347 ± 0.00092 s 0.349 ± 0.00088 s 0.994 ± 0.0036
Memory benchmarks
master 82d75df... master / 82d75df...
multivariate/problems/noisy_quadratic/8/BFGS/BackTracking 0.522 k allocs: 22.1 kB 0.383 k allocs: 16.3 kB 1.36
multivariate/problems/noisy_quadratic/8/BFGS/HagerZhang 1.06 k allocs: 0.0619 MB 1.43 k allocs: 0.0787 MB 0.786
multivariate/problems/noisy_quadratic/8/ConjugateGradient/BackTracking 0.164 k allocs: 8.08 kB 0.164 k allocs: 8.08 kB 1
multivariate/problems/noisy_quadratic/8/ConjugateGradient/HagerZhang 1.59 k allocs: 0.0832 MB 1.59 k allocs: 0.0832 MB 1
multivariate/problems/noisy_quadratic/8/LBFGS/BackTracking 0.246 k allocs: 12.4 kB 0.246 k allocs: 12.4 kB 1
multivariate/problems/noisy_quadratic/8/LBFGS/HagerZhang 1.93 k allocs: 0.109 MB 1.93 k allocs: 0.109 MB 1
multivariate/problems/rosenbrock/100/BFGS 6.54 k allocs: 0.303 MB 6.54 k allocs: 0.303 MB 1
multivariate/problems/rosenbrock/100/KrylovTrustRegion 0.561 k allocs: 0.36 MB 0.561 k allocs: 0.36 MB 1
multivariate/problems/rosenbrock/100/Newton 0.496 k allocs: 0.34 MB 0.496 k allocs: 0.34 MB 1
multivariate/problems/rosenbrock/100/NewtonTrustRegion 0.554 k allocs: 4.65 MB 0.554 k allocs: 4.65 MB 1
multivariate/problems/rosenbrock/2/BFGS 0.591 k allocs: 21.2 kB 0.591 k allocs: 21.2 kB 1
multivariate/problems/rosenbrock/2/KrylovTrustRegion 0.574 k allocs: 21.3 kB 0.574 k allocs: 21.3 kB 1
multivariate/problems/rosenbrock/2/Newton 0.492 k allocs: 18.4 kB 0.492 k allocs: 18.4 kB 1
multivariate/problems/rosenbrock/2/NewtonTrustRegion 0.458 k allocs: 0.0326 MB 0.458 k allocs: 0.0326 MB 1
multivariate/problems/rosenbrock/20/BFGS 2.04 k allocs: 0.0754 MB 1.96 k allocs: 0.0709 MB 1.06
multivariate/problems/rosenbrock/20/KrylovTrustRegion 0.595 k allocs: 0.0616 MB 0.595 k allocs: 0.0616 MB 1
multivariate/problems/rosenbrock/20/Newton 0.496 k allocs: 0.033 MB 0.496 k allocs: 0.033 MB 1
multivariate/problems/rosenbrock/20/NewtonTrustRegion 0.511 k allocs: 0.319 MB 0.511 k allocs: 0.319 MB 1
multivariate/problems/saddle/100/KrylovTrustRegion 0.173 k allocs: 0.211 MB 0.173 k allocs: 0.211 MB 1
multivariate/problems/saddle/100/Newton 0.141 k allocs: 0.352 MB 0.141 k allocs: 0.352 MB 1
multivariate/problems/saddle/100/NewtonTrustRegion 0.914 k allocs: 7.48 MB 0.914 k allocs: 7.48 MB 1
multivariate/problems/saddle/100/NewtonTrustRegion_noise_floor 0.251 k allocs: 2.02 MB 0.251 k allocs: 2.02 MB 1
multivariate/problems/saddle/2/KrylovTrustRegion 0.183 k allocs: 7.09 kB 0.183 k allocs: 7.09 kB 1
multivariate/problems/saddle/2/Newton 0.129 k allocs: 5.75 kB 0.129 k allocs: 5.75 kB 1
multivariate/problems/saddle/2/NewtonTrustRegion 0.18 k allocs: 12.3 kB 0.18 k allocs: 12.3 kB 1
multivariate/problems/saddle/2/NewtonTrustRegion_noise_floor 0.18 k allocs: 12.3 kB 0.18 k allocs: 12.3 kB 1
multivariate/problems/saddle/20/KrylovTrustRegion 0.17 k allocs: 21.9 kB 0.17 k allocs: 21.9 kB 1
multivariate/problems/saddle/20/Newton 0.133 k allocs: 20.8 kB 0.133 k allocs: 20.8 kB 1
multivariate/problems/saddle/20/NewtonTrustRegion 0.222 k allocs: 0.127 MB 0.222 k allocs: 0.127 MB 1
multivariate/problems/saddle/20/NewtonTrustRegion_noise_floor 0.222 k allocs: 0.127 MB 0.222 k allocs: 0.127 MB 1
multivariate/solvers/first_order/AdaMax 0.356 k allocs: 7.27 kB 0.356 k allocs: 7.27 kB 1
multivariate/solvers/first_order/Adam 0.356 k allocs: 7.27 kB 0.356 k allocs: 7.27 kB 1
multivariate/solvers/first_order/BFGS 0.23 k allocs: 8.8 kB 0.23 k allocs: 8.8 kB 1
multivariate/solvers/first_order/ConjugateGradient 0.392 k allocs: 16 kB 0.392 k allocs: 16 kB 1
multivariate/solvers/first_order/GradientDescent 1.52 k allocs: 0.0611 MB 1.52 k allocs: 0.0611 MB 1
multivariate/solvers/first_order/LBFGS 0.319 k allocs: 13.6 kB 0.319 k allocs: 13.6 kB 1
multivariate/solvers/first_order/MomentumGradientDescent 1.91 k allocs: 0.0666 MB 1.91 k allocs: 0.0666 MB 1
multivariate/solvers/first_order/NGMRES 3.14 k allocs: 0.156 MB 3.14 k allocs: 0.156 MB 1
multivariate/solvers/second_order/KrylovTrustRegion 0.284 k allocs: 11.3 kB 0.284 k allocs: 11.3 kB 1
multivariate/solvers/second_order/Newton 0.251 k allocs: 10.2 kB 0.251 k allocs: 10.2 kB 1
multivariate/solvers/second_order/NewtonTrustRegion 0.302 k allocs: 19.1 kB 0.302 k allocs: 19.1 kB 1
time_to_load 0.2 k allocs: 11.7 kB 0.2 k allocs: 11.7 kB 1

A plot of the benchmark results has been uploaded as an artifact at .

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Results (Julia vlts)

Time benchmarks
master 82d75df... master / 82d75df...
multivariate/problems/noisy_quadratic/8/BFGS/BackTracking 29.5 ± 0.38 μs 26.6 ± 0.39 μs 1.11 ± 0.022
multivariate/problems/noisy_quadratic/8/BFGS/HagerZhang 0.218 ± 0.006 ms 0.208 ± 0.0042 ms 1.05 ± 0.035
multivariate/problems/noisy_quadratic/8/ConjugateGradient/BackTracking 15.3 ± 0.34 μs 15.7 ± 0.39 μs 0.973 ± 0.033
multivariate/problems/noisy_quadratic/8/ConjugateGradient/HagerZhang 0.132 ± 0.0023 ms 0.133 ± 0.0017 ms 0.989 ± 0.021
multivariate/problems/noisy_quadratic/8/LBFGS/BackTracking 30.5 ± 0.55 μs 31.4 ± 0.52 μs 0.972 ± 0.024
multivariate/problems/noisy_quadratic/8/LBFGS/HagerZhang 0.188 ± 0.0031 ms 0.192 ± 0.0031 ms 0.978 ± 0.022
multivariate/problems/rosenbrock/100/BFGS 2.81 ± 0.046 ms 3.05 ± 0.035 ms 0.923 ± 0.019
multivariate/problems/rosenbrock/100/KrylovTrustRegion 0.19 ± 0.011 ms 0.188 ± 0.0074 ms 1.01 ± 0.071
multivariate/problems/rosenbrock/100/Newton 1.32 ± 0.019 ms 1.32 ± 0.03 ms 1 ± 0.027
multivariate/problems/rosenbrock/100/NewtonTrustRegion 6.45 ± 0.65 ms 6.42 ± 0.13 ms 1.01 ± 0.1
multivariate/problems/rosenbrock/2/BFGS 0.0338 ± 0.00063 ms 0.0349 ± 0.00061 ms 0.968 ± 0.025
multivariate/problems/rosenbrock/2/KrylovTrustRegion 25.6 ± 0.44 μs 25.6 ± 0.48 μs 1 ± 0.026
multivariate/problems/rosenbrock/2/Newton 0.033 ± 0.00054 ms 0.0326 ± 0.00055 ms 1.01 ± 0.024
multivariate/problems/rosenbrock/2/NewtonTrustRegion 0.0392 ± 0.00089 ms 0.0386 ± 0.00085 ms 1.02 ± 0.032
multivariate/problems/rosenbrock/20/BFGS 0.198 ± 0.0074 ms 0.206 ± 0.0069 ms 0.965 ± 0.048
multivariate/problems/rosenbrock/20/KrylovTrustRegion 0.0469 ± 0.0012 ms 0.0454 ± 0.0012 ms 1.03 ± 0.038
multivariate/problems/rosenbrock/20/Newton 0.0849 ± 0.0011 ms 0.0853 ± 0.0011 ms 0.996 ± 0.018
multivariate/problems/rosenbrock/20/NewtonTrustRegion 0.358 ± 0.013 ms 0.355 ± 0.014 ms 1.01 ± 0.054
multivariate/problems/saddle/100/KrylovTrustRegion 0.0674 ± 0.0016 ms 0.0683 ± 0.0022 ms 0.987 ± 0.04
multivariate/problems/saddle/100/Newton 0.253 ± 0.013 ms 0.252 ± 0.013 ms 1.01 ± 0.076
multivariate/problems/saddle/100/NewtonTrustRegion 10.7 ± 0.51 ms 10.6 ± 0.47 ms 1.01 ± 0.066
multivariate/problems/saddle/100/NewtonTrustRegion_noise_floor 2.48 ± 0.03 ms 2.47 ± 0.037 ms 1 ± 0.02
multivariate/problems/saddle/2/KrylovTrustRegion 9.53 ± 0.36 μs 9.67 ± 0.43 μs 0.986 ± 0.058
multivariate/problems/saddle/2/Newton 7.09 ± 0.24 μs 6.97 ± 0.25 μs 1.02 ± 0.05
multivariate/problems/saddle/2/NewtonTrustRegion 14.3 ± 1.8 μs 14 ± 1.9 μs 1.02 ± 0.19
multivariate/problems/saddle/2/NewtonTrustRegion_noise_floor 13.8 ± 0.54 μs 13.7 ± 0.51 μs 1.01 ± 0.055
multivariate/problems/saddle/20/KrylovTrustRegion 14.9 ± 0.55 μs 14.3 ± 0.72 μs 1.04 ± 0.065
multivariate/problems/saddle/20/Newton 17.8 ± 0.57 μs 17.8 ± 0.72 μs 0.998 ± 0.051
multivariate/problems/saddle/20/NewtonTrustRegion 0.125 ± 0.0035 ms 0.125 ± 0.003 ms 1 ± 0.037
multivariate/problems/saddle/20/NewtonTrustRegion_noise_floor 0.125 ± 0.0033 ms 0.125 ± 0.003 ms 1 ± 0.036
multivariate/solvers/first_order/AdaMax 0.549 ± 0.01 ms 0.553 ± 0.011 ms 0.992 ± 0.027
multivariate/solvers/first_order/Adam 0.549 ± 0.01 ms 0.551 ± 0.01 ms 0.997 ± 0.026
multivariate/solvers/first_order/BFGS 0.0949 ± 0.0011 ms 0.0952 ± 0.0013 ms 0.997 ± 0.018
multivariate/solvers/first_order/ConjugateGradient 0.3 ± 0.011 ms 0.301 ± 0.011 ms 0.998 ± 0.051
multivariate/solvers/first_order/GradientDescent 0.793 ± 0.0054 ms 0.795 ± 0.0054 ms 0.997 ± 0.0096
multivariate/solvers/first_order/LBFGS 0.11 ± 0.0014 ms 0.11 ± 0.0016 ms 0.995 ± 0.019
multivariate/solvers/first_order/MomentumGradientDescent 1.54 ± 0.012 ms 1.54 ± 0.013 ms 0.999 ± 0.011
multivariate/solvers/first_order/NGMRES 0.461 ± 0.016 ms 0.454 ± 0.014 ms 1.02 ± 0.047
multivariate/solvers/second_order/KrylovTrustRegion 0.474 ± 0.01 ms 0.473 ± 0.011 ms 1 ± 0.032
multivariate/solvers/second_order/Newton 0.161 ± 0.0026 ms 0.16 ± 0.0025 ms 1 ± 0.022
multivariate/solvers/second_order/NewtonTrustRegion 0.233 ± 0.0085 ms 0.232 ± 0.0098 ms 1 ± 0.056
time_to_load 0.336 ± 0.0017 s 0.331 ± 0.0033 s 1.02 ± 0.011
Memory benchmarks
master 82d75df... master / 82d75df...
multivariate/problems/noisy_quadratic/8/BFGS/BackTracking 0.337 k allocs: 19 kB 0.295 k allocs: 17 kB 1.12
multivariate/problems/noisy_quadratic/8/BFGS/HagerZhang 1.65 k allocs: 0.122 MB 1.54 k allocs: 0.117 MB 1.05
multivariate/problems/noisy_quadratic/8/ConjugateGradient/BackTracking 0.135 k allocs: 8.97 kB 0.135 k allocs: 8.97 kB 1
multivariate/problems/noisy_quadratic/8/ConjugateGradient/HagerZhang 0.969 k allocs: 0.0783 MB 0.969 k allocs: 0.0783 MB 1
multivariate/problems/noisy_quadratic/8/LBFGS/BackTracking 0.343 k allocs: 20 kB 0.343 k allocs: 20 kB 1
multivariate/problems/noisy_quadratic/8/LBFGS/HagerZhang 1.41 k allocs: 0.11 MB 1.41 k allocs: 0.11 MB 1
multivariate/problems/rosenbrock/100/BFGS 5.11 k allocs: 0.323 MB 5.1 k allocs: 0.323 MB 1
multivariate/problems/rosenbrock/100/KrylovTrustRegion 0.328 k allocs: 0.352 MB 0.328 k allocs: 0.352 MB 1
multivariate/problems/rosenbrock/100/Newton 0.369 k allocs: 0.339 MB 0.369 k allocs: 0.339 MB 1
multivariate/problems/rosenbrock/100/NewtonTrustRegion 0.416 k allocs: 6.43 MB 0.416 k allocs: 6.43 MB 1
multivariate/problems/rosenbrock/2/BFGS 0.449 k allocs: 22.8 kB 0.449 k allocs: 22.8 kB 1
multivariate/problems/rosenbrock/2/KrylovTrustRegion 0.332 k allocs: 21.4 kB 0.332 k allocs: 21.4 kB 1
multivariate/problems/rosenbrock/2/Newton 0.365 k allocs: 18.7 kB 0.365 k allocs: 18.7 kB 1
multivariate/problems/rosenbrock/2/NewtonTrustRegion 0.343 k allocs: 0.0348 MB 0.343 k allocs: 0.0348 MB 1
multivariate/problems/rosenbrock/20/BFGS 1.58 k allocs: 0.0789 MB 1.51 k allocs: 0.0766 MB 1.03
multivariate/problems/rosenbrock/20/KrylovTrustRegion 0.343 k allocs: 0.0618 MB 0.343 k allocs: 0.0618 MB 1
multivariate/problems/rosenbrock/20/Newton 0.365 k allocs: 0.0334 MB 0.365 k allocs: 0.0334 MB 1
multivariate/problems/rosenbrock/20/NewtonTrustRegion 0.33 k allocs: 0.396 MB 0.33 k allocs: 0.396 MB 1
multivariate/problems/saddle/100/KrylovTrustRegion 0.107 k allocs: 0.209 MB 0.107 k allocs: 0.209 MB 1
multivariate/problems/saddle/100/Newton 0.096 k allocs: 0.351 MB 0.096 k allocs: 0.351 MB 1
multivariate/problems/saddle/100/NewtonTrustRegion 0.701 k allocs: 10.4 MB 0.701 k allocs: 10.4 MB 1
multivariate/problems/saddle/100/NewtonTrustRegion_noise_floor 0.183 k allocs: 2.71 MB 0.183 k allocs: 2.71 MB 1
multivariate/problems/saddle/2/KrylovTrustRegion 0.114 k allocs: 7.16 kB 0.114 k allocs: 7.16 kB 1
multivariate/problems/saddle/2/Newton 0.088 k allocs: 5.52 kB 0.088 k allocs: 5.52 kB 1
multivariate/problems/saddle/2/NewtonTrustRegion 0.129 k allocs: 13 kB 0.129 k allocs: 13 kB 1
multivariate/problems/saddle/2/NewtonTrustRegion_noise_floor 0.129 k allocs: 13 kB 0.129 k allocs: 13 kB 1
multivariate/problems/saddle/20/KrylovTrustRegion 0.102 k allocs: 22.1 kB 0.102 k allocs: 22.1 kB 1
multivariate/problems/saddle/20/Newton 0.088 k allocs: 20.7 kB 0.088 k allocs: 20.7 kB 1
multivariate/problems/saddle/20/NewtonTrustRegion 0.139 k allocs: 0.155 MB 0.139 k allocs: 0.155 MB 1
multivariate/problems/saddle/20/NewtonTrustRegion_noise_floor 0.139 k allocs: 0.155 MB 0.139 k allocs: 0.155 MB 1
multivariate/solvers/first_order/AdaMax 0.341 k allocs: 7.36 kB 0.341 k allocs: 7.36 kB 1
multivariate/solvers/first_order/Adam 0.341 k allocs: 7.36 kB 0.341 k allocs: 7.36 kB 1
multivariate/solvers/first_order/BFGS 0.157 k allocs: 8.67 kB 0.157 k allocs: 8.67 kB 1
multivariate/solvers/first_order/ConjugateGradient 0.471 k allocs: 24.4 kB 0.471 k allocs: 24.4 kB 1
multivariate/solvers/first_order/GradientDescent 1.22 k allocs: 0.065 MB 1.22 k allocs: 0.065 MB 1
multivariate/solvers/first_order/LBFGS 0.22 k allocs: 13.6 kB 0.22 k allocs: 13.6 kB 1
multivariate/solvers/first_order/MomentumGradientDescent 1.62 k allocs: 0.0713 MB 1.62 k allocs: 0.0713 MB 1
multivariate/solvers/first_order/NGMRES 1.68 k allocs: 0.142 MB 1.68 k allocs: 0.142 MB 1
multivariate/solvers/second_order/KrylovTrustRegion 0.229 k allocs: 13.5 kB 0.229 k allocs: 13.5 kB 1
multivariate/solvers/second_order/Newton 0.174 k allocs: 10.3 kB 0.174 k allocs: 10.3 kB 1
multivariate/solvers/second_order/NewtonTrustRegion 0.253 k allocs: 23 kB 0.253 k allocs: 23 kB 1
time_to_load 0.153 k allocs: 14.5 kB 0.153 k allocs: 14.5 kB 1

A plot of the benchmark results has been uploaded as an artifact at .

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.24%. Comparing base (5854234) to head (82d75df).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1291      +/-   ##
==========================================
+ Coverage   88.20%   88.24%   +0.04%     
==========================================
  Files          52       52              
  Lines        5080     5072       -8     
==========================================
- Hits         4481     4476       -5     
+ Misses        599      596       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@pkofod pkofod left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The symmetric update is correct and I'd like to keep it. I checked exact symmetry on all 17 unconstrained OptimTestProblems and on 150 random complex problems: 0 asymmetric (or non-Hermitian) states with this PR, against about 99% on master, and every problem still converges.

The broadcast is a performance regression for Matrix, though. BFGS time per iteration on Extended Rosenbrock (analytic O(n) gradient, so the O(n²) update dominates), Ryzen 5 2600, one thread, relative to the base commit:

Julia n = 100 n = 500 n = 1000
1.10 1.76x 1.94x 1.48x
1.11 1.03x 1.05x 1.02x
1.12 1.76x 1.95x 1.54x
1.13 1.82x 1.95x 1.52x

An M-series Mac shows the same pattern. On 1.12 and 1.13 the update block alone is about 2x slower than the muladd loop from n = 32 on. On aarch64, LLVM emits 9 to 11 vectorized loop versions for this 2D broadcast on 1.12 and 1.13, against 38 to 46 on 1.10 and 1.11. On 1.10 the update block alone is only about 10% slower at n = 100 to 200, and I haven't found why it costs this much end to end. The CI benchmark misses all of it because BFGS only runs on the 2-parameter Gabor problem.

Suggestion: keep your formula, but restore the Array branch with a plain loop (inline suggestion below). It gives bitwise the same invH as the broadcast for real and complex inputs, so your argument and test still hold. Other arrays keep the broadcast, which for FixedSizeArray is still faster than master's three mul! calls (up to 12x at small n, 1.3 to 1.6x at n ≥ 50 on x86). End to end, the loop stays within 0.98 to 1.03 of master at n ≥ 100 on all four versions. It must not use muladd, since fusing rounds (i, j) and (j, i) differently.

The loop needs @inbounds: without it, it is up to 3x slower than master on 1.11 and 1.12 for n ≥ 16. If you'd rather avoid @inbounds, this column-wise version needs none and cost up to 8% per iteration in my runs:

for j in axes(invH, 2)
    sj = dx[j]'
    wj = u[j]'
    @views invH[:, j] .+= vec(dx) .* wj .+ vec(u) .* sj
end

It would also be worth adding BFGS to the Rosenbrock benchmarks that scale with n, so CI catches this kind of change.

The benchmarks and this review were prepared with the assistance of Claude Code.

Comment on lines +209 to +210
u .= (c1 / 2) .* dx .- c2 .* u
invH .+= vec(dx) .* vec(u)' .+ vec(u) .* vec(dx)'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gives bitwise the same invH as the broadcast, and matches master's speed for Array:

Suggested change
u .= (c1 / 2) .* dx .- c2 .* u
invH .+= vec(dx) .* vec(u)' .+ vec(u) .* vec(dx)'
u .= (c1 / 2) .* dx .- c2 .* u
if invH isa Array
# for Array a loop is much faster than the broadcast below on Julia 1.10 and 1.12+
@inbounds for j = 1:length(dx)
sj = dx[j]'
wj = u[j]'
for i = 1:length(dx)
invH[i, j] += dx[i] * wj + u[i] * sj
end
end
else
invH .+= vec(dx) .* vec(u)' .+ vec(u) .* vec(dx)'
end

@devmotion

Copy link
Copy Markdown
Collaborator Author

So performance is not impacted on Julia 1.11? Generally, given all the calculations involved in each iteration, is this arguably limited performance regression relevant in practice? Does the comparison need @inbounds (IMO its use should generally/usually be avoided)? Having separate branches, as on master, makes the code more complex and makes it easy to make a branch too specific and miss testing some of the branches (as the case on master it seems?), so I think there has to be a strong motivation for adding more specific branches (and why only Array, why not eg DenseArray or StridedArray or ....?).

@pkofod

pkofod commented Oct 27, 2026

Copy link
Copy Markdown
Member

So performance is not impacted on Julia 1.11?

yes, as far as I understand it, 1.11 compiles some optimized version for your formula that are not there on v1.12 and v1.13.

given all the calculations involved in each iteration, is this arguably limited performance regression relevant in practice?

That depends

@pkofod

pkofod commented Oct 27, 2026

Copy link
Copy Markdown
Member

A note on the 1.11 answer. It comes from counting the vectorized loop bodies (vector.body blocks) in code_llvm of the copyto! that invH .+= vec(dx) .* vec(u)' .+ vec(u) .* vec(dx)' lowers to, for Matrix{Float64} on aarch64:

Julia vectorized loop bodies
1.10 38
1.11 46
1.12 9
1.13 11

The loop on master, the @inbounds loop and the column-wise version have the same counts on every version (1, 3 and 8).

I only counted them. I didn't check which ones were generated, so this doesn't show which version runs on the hot path, and I haven't found the compiler change behind the drop. It also doesn't explain 1.10: it has the vectorized versions but is still 1.48 to 1.94x slower per iteration at n ≥ 100 in the table above.

Prepared with the assistance of Claude Code.

@pkofod

pkofod commented Oct 27, 2026

Copy link
Copy Markdown
Member

I can try to see which ones were generated.

@pkofod

pkofod commented Oct 27, 2026

Copy link
Copy Markdown
Member

I checked which versions run. I sampled instruction addresses with the profiler and mapped them onto the JIT'd machine code of accept_step! during optimize(f, g!, x0, BFGS()) on Extended Rosenbrock, n = 200 (Apple M2 Pro):

  • 1.11: the hot loop is the vectorized body where every operand is a vector load, 2 wide and interleaved 4x.
  • 1.10, 1.12 and 1.13: that body is never entered. The time goes to the scalar remainder loop, one element per iteration, for every column.

So the vectorized versions are generated on every version, but on 1.10, 1.12 and 1.13 they never run for this call.

Why. invH .+= ... lowers to invH .= invH .+ .... The source invH is extruded with runtime flags, so it is read as invH[size(invH,1) == 1 ? 1 : i, size(invH,2) == 1 ? 1 : j] while the write goes to invH[i, j]. The two are only the same address after LLVM's loop unswitching clones the loop on the column flag. On 1.12 and 1.13 it doesn't: the clone exceeds the unswitch cost threshold once the multiplier for repeated unswitching is applied, so the column index stays a select. The vectorizer then can't prove that the read and the write are the same element. It emits a runtime overlap check of invH against itself, and that check always reports a conflict.

On 1.11 the load and the store share one address and that check doesn't exist. In an isolated benchmark of the PR's two lines, lowering the limit with -unswitch-threshold=25 makes 1.11 2 to 4x slower than master's loop. The 1.10 case is the same effect: the isolated benchmark happened to unswitch on 1.10 and accept_step! does not, which is why the update block alone looked only about 10% slower there.

A second, smaller effect on 1.12 and 1.13. When the vector body does run, the loop vectorizer estimates its cost at 20 to 22 (18 to 19 on 1.11). That is at or above its small-loop-cost threshold of 20, so the loop isn't interleaved. This is worth about 20%.

Time per iteration, PR / base commit, n = 100 / 200 / 500, with the flags passed through JULIA_LLVM_ARGS:

Julia PR PR, -enable-unswitch-cost-multiplier=false PR, that + -small-loop-cost=30
1.10 1.65 / 1.56 / 1.73 1.06 / 0.96 / 1.00
1.11 1.04 / 0.95 / 0.99
1.12 1.75 / 1.70 / 1.88 1.04 / 1.06 / 1.02 1.02 / 1.00 / 1.00
1.13 1.66 / 1.72 / 1.74 1.03 / 1.08 / 1.03 1.02 / 1.00 / 1.00

The flags leave the base commit unchanged within 1%. The n = 500 runs hit the 1000-iteration limit.

Still open: why 1.12 hits the unswitch limit where 1.11 doesn't. The 1.12 IR computes the addresses with byte-offset GEPs and more instructions, but I haven't shown that this is what raises the cost. I only looked at the machine code on Apple Silicon.

So to the 1.11 question: 1.11 is fast here because it happens to unswitch this loop. The same broadcast can fall back to the scalar loop on any version, depending on the code around it.

Prepared with the assistance of Claude Code.

@pkofod

pkofod commented Oct 28, 2026

Copy link
Copy Markdown
Member

How large is this compared to a whole optimize call? I timed optimize(f, g!, x0, BFGS()) on a diagonal quadratic f(x) = sum(d .* x .^ 2) / 2 with d from 1 to 100, which is about as cheap as an objective gets. The base commit and this PR take the same number of iterations and calls, so the whole-run ratio is also the per-iteration ratio.

Whole run, PR / base (Apple M2 Pro):

n iterations 1.10 1.11 1.12 1.13
10 10 1.06 1.08 1.08 1.06
50 44 1.41 1.01 1.51 1.53
100 61 1.65 1.00 1.75 1.80
200 79 1.53 0.95 1.69 1.75

On 1.12 that is 5.6 to 9.7 µs per iteration at n = 100 and 24 to 41 µs at n = 200. A full run at n = 200 goes from 1.9 ms to 3.3 ms.

The added time is a fixed amount per iteration and does not depend on f: about 4 µs at n = 100 and 17 µs at n = 200. These runs make 2 f and 2 g! calls per iteration, so the slowdown falls below 10% once one f plus g! evaluation costs more than about 20 µs at n = 100, or about 75 µs at n = 200.

Prepared with the assistance of Claude Code.

@pkofod

pkofod commented Oct 28, 2026

Copy link
Copy Markdown
Member

we can condition the branch on the Julia version ;)

Inlined into `accept_step!`, LLVM does not unswitch the broadcast loop on
Julia 1.10 and 1.12+, so its vectorized body is never entered and BFGS
iterations become up to 1.7x slower than master. In a separate function the
loop is vectorized again, without an `Array` branch or `@inbounds`.

Also benchmark BFGS on Rosenbrock with n = 2, 20, 100, where the O(n^2)
update dominates for larger n.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@devmotion

Copy link
Copy Markdown
Collaborator Author

Thanks for tracking down the unswitching. That points to a fix without a branch: in 478212e the broadcast moved into a small function of its own, _symmetric_rank2_update!. It needs no @inbounds and no @noinline, and there's still one code path for Array, FixedSizeArray and GPU arrays.

BFGS time per iteration on Extended Rosenbrock relative to master, n = 100 to 500, Apple M2 Pro:

variant 1.10 1.11 1.12 1.13
broadcast inline 1.56–1.72 0.94–1.02 1.69–1.75 1.64–1.74
@inbounds loop for Array 0.96–1.04 0.93–1.00 1.00–1.02 1.00–1.01
broadcast in a separate function 0.96–1.05 0.93–1.02 1.03–1.10 1.02–1.07

This still depends on what LLVM decides, so I added BFGS on Rosenbrock with n = 2, 20 and 100 to the benchmarks. CI should catch it if this comes back.

If we ever need a branch, I don't think Array is the right condition. It leaves out FixedSizeArray and views, and DenseArray would include GPU arrays. The property that matters is fast scalar indexing, which needs ArrayInterface. The column-wise version and FastBroadcast didn't do better: the first indexes elements one at a time, which fails on GPU arrays, and the second adds a dependency.

Prepared with the assistance of Claude Code.

@pkofod
pkofod merged commit 35dd166 into master Oct 28, 2026
23 checks passed
@devmotion
devmotion deleted the dmw/bfgs-symmetric-update branch October 28, 2026 11:24
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