Skip to content

Faster solve!: BLAS-friendly AIC layout + leaner filament kernels (5.4-8.0x) - #259

Merged
1-Bart-1 merged 7 commits into
mainfrom
faster-aic-layout
Aug 17, 2026
Merged

Faster solve!: BLAS-friendly AIC layout + leaner filament kernels (5.4-8.0x)#259
1-Bart-1 merged 7 commits into
mainfrom
faster-aic-layout

Conversation

@1-Bart-1

@1-Bart-1 1-Bart-1 commented Aug 16, 2026

Copy link
Copy Markdown
Member

Two independent changes to the solve! hot path. Combined effect at n=120: 8.0x (inviscid) and 5.4x (polars).

1. AIC stored component-last

BodyAerodynamics.AIC was (3, n_panels, n_panels), so each component slice AIC[k, :, :] had stride1 == 3. That is not a valid BLAS layout, so the three mul! calls in update_gamma_candidate! silently fell back to the generic Julia matmul instead of gemv.

With the default relaxation_factor = 0.03 the gamma loop runs ~247 iterations, so those three matvecs were ~80% of total solve! time. Isolated, the strided slice was 22-26x slower than the same matrix made contiguous.

Storing AIC as (n_panels, n_panels, 3) makes every component slice contiguous.

Also in this commit:

  • AIC build loop nest swapped to jring outer, so the writes are unit-stride and the filament tuple hoists out of the inner loop.
  • The hand-rolled triple loop in update_effective_angle_of_attack! becomes three mul! calls.
  • f_body_3D / m_body_3D no longer borrow a 3 x n slice of AIC as a LazyBufferCache shape template (there isn't one any more); they ask for the size directly.

AIC is an internal buffer (its only accessor, calculate_AIC_matrices!, is listed under private functions), so this is not a breaking change; internal code reading AIC[k, i, j] becomes AIC[i, j, k].

2. Redundant work removed from the filament kernels

The three induced-velocity kernels run n^2 times per solve!, and each recomputed things it already had or did not need:

  • r0 and |r0| are reinit!-maintained on every BoundFilament, but velocity_3D_trailing_vortex! rebuilt both from x1/x2 each call and velocity_3D_bound_vortex! re-normed the stored vector.
  • The core-radius cutoff only needs |r_perp|, and r_perp is a projection onto r0 (or Vf), so its length is |r1.r0|/|r0| — the vector is now built only inside the core branch that actually uses it.
  • r1 x r2, the r1/|r1| - r2/|r2| difference, and r2 x r0 were computed before the branch selecting between the regular and core-radius forms, but each is read on only one side of it.

Bit-identical: max deviation 0.0 over 28800 calls against the previous implementation.

The kernels keep their MVector scratch. An SVector rewrite is 1.14x faster again for Float64 but 1.2-1.4x slower for ForwardDiff.Dual (measured 0.84x at N=4, 0.69x at N=12), which linearize depends on — so it was not worth the trade.

Numbers

main after (1) after (1)+(2) total
INVISCID n=60 5.92 ms 1.72 ms 1.17 ms 5.05x
INVISCID n=120 26.10 ms 4.89 ms 3.28 ms 7.96x
POLAR_VECTORS n=60 6.44 ms 2.11 ms 1.43 ms 4.51x
POLAR_VECTORS n=120 21.06 ms 6.05 ms 3.91 ms 5.39x

Allocation counts unchanged throughout (13-14).

3. Docstring fixes

The Solver docstring listed type_initial_gamma_distribution as ELLIPTIC (it is ZEROS) and core_radius_fraction as 1e-20 (it is 0.05).

Testing

Induction Matrix Creation                      6/6      <- AIC vs reference matrices, atol=1e-8
Calculate results against output results      26/26
Wing Geometry Creation                      4803/4803
set_va! (3 testsets)                          23/23
Linearize Jacobian validation                 30/30     <- ForwardDiff/Dual path
Solver Constructor                             3/3
NONLIN solve! re-runs across calls             2/2
calc_forces! is zero-alloc                     1/1
Spanwise Laplacian tip closures                5/5
apply_artificial_viscosity!                    5/5
solve! artificial viscosity                    2/2
Panel Tests                                   24/24
BoundFilament Tests                          105/105
SemiInfiniteFilament Tests                    58/58
frozen_wake! Tests                             3/3

Zero failures, run after both changes. The geometry/IO groups were left to CI.

🤖 Generated with Claude Code


3. SolverSettings defaults aligned with Solver

Solver and SolverSettings disagreed on two defaults, and Solver(settings)
passed the divergence straight through, so the same nominal default meant
different things depending on how you configured the solve:

Solver SolverSettings (before)
core_radius_fraction 0.05 1e-20
type_initial_gamma_distribution ZEROS ELLIPTIC

SolverSettings now uses 0.05 / ZEROS. 0.05 is the upstream
awegroup/Vortex-Step-Method
default, attributed there to Damiani et al. (2019), A Vortex Step Method for
Nonlinear Airfoil Polar Data as Implemented in KiteAeroDyn
; upstream likewise
defaults gamma_initial_distribution_type to "zero".

This moves no numbers. At 1e-20 the Biot-Savart singularity guard simply
never engaged. The branch fires when perp_dist/‖r0‖ < core_radius_fraction,
and for a wing that ratio is 0.5·n/AR, so triggering needs n < 0.1·AR
under 2 panels on an AR-20 wing. Verified on an arc wing (60° half-arc, R=8),
where bound filaments are genuinely non-collinear: dCL = 0.0 and
max‖Δγ‖/max‖γ‖ = 0.0 at α = 5°/10°/20°, n = 40/80. The fix matters for
degenerate geometry (near-touching panels, deformed or multi-body layouts), not
for coefficient accuracy. Every settings file in data/ sets both keys
explicitly, so none of them change.

Also documented core_radius_fraction in both docstrings (it had an empty
description in Solver) and corrected three YAML comments that called it a
fraction of chord — it is a fraction of filament length, i.e. panel width.

Tests

Full suite green on this branch: 6131 pass, 0 fail, 10m09.7s (the single
broken is a pre-existing @test_broken in Kite Geometry Tests).


4. CI: keep test-env precompilation lazy on macOS and Windows

Unrelated to the changes above, but it was blocking this PR and affects main
equally, so it is fixed here rather than split out.

macOS-aarch64 and Windows-x64 began failing on every branch with:

GLFWError (FORMAT_UNAVAILABLE): NSGL: Failed to find a suitable pixel format
Failed to precompile GLMakie → MakieControlPlots → VortexStepMethodMakieExt

Isolated by diffing the last green main run against a failing one — only the
Julia patch version differed:

Aug 13 (pass) Aug 17 (fail)
Julia 1.12.6 1.12.7
Runner image macos-26-arm64 20260728.0273.1 identical
GLMakie / GLFW / GLFW_jll 0.13.13 / 3.4.6 / 3.4.1+1 identical

test/runtests.jl already skips plotting off Linux, and the Aug 13 macOS log
prints Skipping plotting tests on Darwin: GLMakie needs a display. and passes.
But that guard runs at test time; 1.12.7 precompiles the whole test
environment first, so MakieControlPlots (top-level using GLMakie) loads and
fails before the guard is reached. CI.yml only provisions xvfb on Linux.

Fix — keep precompilation lazy where there is no display, so the existing guard
is reached before GLMakie is ever loaded:

JULIA_PKG_PRECOMPILE_AUTO: ${{ runner.os == 'Linux' && '1' || '0' }}

macOS now reports 6060 pass, 1 broken in 4m41s (was 15m43s failing). The
71-test gap versus Linux's 6131 is exactly the Linux-only plotting testsets
(58 Plotting + 13 Airfoil skin), so nothing is silently skipped.

Trade-off worth knowing: those two jobs no longer get an up-front precompile of
the test environment, so a genuine precompile error in a non-plotting test dep
would surface as a load error mid-suite rather than cleanly before it starts.
Fixing it at the source instead would mean moving MakieControlPlots out of
test/Project.toml into a Linux-only job with its own environment.

All 9 checks green.

`BodyAerodynamics.AIC` was `(3, n_panels, n_panels)`, so each component slice
`AIC[k, :, :]` had `stride1 == 3`. That is not a BLAS layout, so the three
`mul!` calls in the gamma loop silently fell back to the generic Julia
matmul — over 247 iterations that was ~80% of `solve!`.

Storing it as `(n_panels, n_panels, 3)` makes every slice contiguous and the
products dispatch to `gemv`:

                       before      after     speedup
  INVISCID      n=60    5.92 ms    1.72 ms    3.43x
  INVISCID      n=120  26.10 ms    4.89 ms    5.34x
  POLAR_VECTORS n=60    6.44 ms    2.11 ms    3.06x
  POLAR_VECTORS n=120  21.06 ms    6.05 ms    3.48x

Also swaps the AIC build loop nest to `jring` outer so the writes are
unit-stride and the filament tuple hoists, replaces the hand-rolled triple
loop in `update_effective_angle_of_attack!` with three `mul!` calls, and
drops the AIC-as-shape-template for `f_body_3D`/`m_body_3D`, which no longer
has a 3xn slice to borrow.

Allocation counts are unchanged and `calc_forces! is zero-alloc` still holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

1-Bart-1 and others added 2 commits August 17, 2026 00:38
The three induced-velocity kernels run n^2 times per `solve!`, and each was
recomputing quantities it either already had or did not need:

- `r0` and `|r0|` are `reinit!`-maintained on every `BoundFilament`, but
  `velocity_3D_trailing_vortex!` rebuilt both from `x1`/`x2` each call and
  `velocity_3D_bound_vortex!` re-normed the stored vector.
- The core-radius cutoff only needs `|r_perp|`, and `r_perp` is a projection
  onto `r0` (or onto `Vf`), so its length is `|r1.r0|/|r0|` — no need to form
  the vector. It is still built inside the core branch, where it is used.
- `r1 x r2`, the `r1/|r1| - r2/|r2|` difference and `r2 x r0` were computed
  before the branch that selects between the regular and core-radius forms,
  but each is read on only one side of it.

Bit-identical output: max deviation 0.0 over 28800 calls against the previous
implementation, and the wing coefficients are unchanged.

  INVISCID      n=60   1724.0 us -> 1173.3 us   1.47x
  INVISCID      n=120  4889.0 us -> 3277.7 us   1.49x
  POLAR_VECTORS n=60   2106.2 us -> 1428.4 us   1.47x
  POLAR_VECTORS n=120  6047.6 us -> 3908.3 us   1.55x

The kernels keep their `MVector` scratch. An `SVector` rewrite is 1.14x faster
again for `Float64` but 1.2-1.4x *slower* for `ForwardDiff.Dual`, which
`linearize` depends on, so it is not worth the trade.

Also fixes two stale defaults in the `Solver` docstring:
`type_initial_gamma_distribution` is `ZEROS`, not `ELLIPTIC`, and
`core_radius_fraction` is `0.05`, not `1e-20`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@1-Bart-1 1-Bart-1 changed the title Store AIC component-last: 3.1-5.3x faster solve! Faster solve!: BLAS-friendly AIC layout + leaner filament kernels (5.4-8.0x) Aug 16, 2026
1-Bart-1 and others added 4 commits August 17, 2026 09:35
The Solver docstring carried the SolverSettings defaults rather than its own
and left core_radius_fraction undescribed. 0.05 is the upstream VSM default,
attributed there to Damiani et al. (2019).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
core_radius_fraction 1e-20 -> 0.05 and type_initial_gamma_distribution
ELLIPTIC -> ZEROS. 0.05 is the upstream awegroup/Vortex-Step-Method default,
attributed there to Damiani et al. (2019), "A Vortex Step Method for Nonlinear
Airfoil Polar Data as Implemented in KiteAeroDyn".

At 1e-20 the Biot-Savart singularity guard never engaged. Coefficients are
unchanged for well-separated geometry: the guard triggers only when a control
point falls within 5% of a filament length of a bound vortex, and that ratio is
0.5*n/AR for a wing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Julia 1.12.7 precompiles the whole test environment before running any test,
so MakieControlPlots and GLMakie load ahead of the Sys.islinux() guard in
runtests.jl. Only Linux sets up xvfb, so GLMakie fails to open a GL context
there: "GLFWError (FORMAT_UNAVAILABLE): NSGL: Failed to find a suitable pixel
format". Same runner image, same GLMakie/GLFW versions as the last green run
on 1.12.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIC is an internal buffer; its only accessor, calculate_AIC_matrices!, is
documented under private functions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@1-Bart-1
1-Bart-1 merged commit 006c656 into main Aug 17, 2026
8 checks passed
@1-Bart-1
1-Bart-1 deleted the faster-aic-layout branch August 17, 2026 09:35
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.

1 participant