Skip to content

perf: upgrade INS.jl tesseract to v5.0.0 (native ν gradient, in-place forward, float32)#103

Draft
agdestein wants to merge 4 commits into
pasteurlabs:mainfrom
agdestein:ins-jl-v5-upgrade
Draft

perf: upgrade INS.jl tesseract to v5.0.0 (native ν gradient, in-place forward, float32)#103
agdestein wants to merge 4 commits into
pasteurlabs:mainfrom
agdestein:ins-jl-v5-upgrade

Conversation

@agdestein

@agdestein agdestein commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to agdestein/IncompressibleNavierStokes.jl#197 and the companion wording PR (#102). This upgrades the INS.jl tesseract to the just-released v5.0.0 and fixes three things that skewed the reported numbers. Every INS.jl symbol used by this tesseract is unchanged in v5.0.0, so the upgrade itself is just the version pin.

Suggested label: benchmark:solver — INS.jl numbers change; please re-run the ins_jl cells.

Changes that affect published numbers

  1. VJP: native viscosity gradient replaces the finite-difference fallback.
    INS.jl v5.0.0's diffusion rrule provides the ν cotangent (the diffusive term is linear in ν, so it is exact). ns_vjp now takes grad_v0, grad_dt, and grad_nu from a single Zygote pullback. This removes two full forward rollouts from every VJP call — the FD fallback ran unconditionally, even though vjp_cost (jax.grad w.r.t. v0) never requested the ν gradient, inflating all reported INS.jl VJP times by ~25–36%.
  2. Forward pass: in-place solve_unsteady instead of the AD-ready rollout.
    apply has no gradient tape to build, so it now uses the library's in-place time stepper (RKMethods.RK44, which applies the same per-stage BC + projection as the projected RHS used for the VJP primal). Measured at the 2-D cost point (N=256, 100 steps, CPU): 2.49 s → 0.56 s, with interiors agreeing to round-off (≤4e-14 relative in a Float64 A/B test). The VJP primal keeps the non-mutating rollout — see the validation section for the cross-check.
  3. The solve now actually runs in Float32 (internal_dtype: float64 → float32).
    Previously the Setup was built from Float64 coordinates, and INS.jl's non-mutating operators allocate their outputs from the setup grid — so the first projection silently promoted the state (and the whole Zygote tape) to Float64, despite the Float32 casts at every interface: Float64 compute cost with Float32 I/O precision. (A bare 0.5 literal in the collocated↔staggered helpers had the same effect; they are now eltype-preserving.) Building the grid in Float32 makes forward + VJP run in single precision end-to-end, like the other float32 solvers. Note this will move INS.jl's FD-gradient-error numbers to the float32 noise floor of its peers — the previous best-in-class values partly reflected the hidden double precision.
    (If you'd rather keep INS.jl in genuine Float64 and disclose the dtype difference instead, that is a one-line revert of the LinRange change — happy to go either way.)

Changes with identical results (pure speedup)

  1. Removed the per-stage add_ghosts/strip_ghosts wrapping in the RK4 rollout used for the VJP primal. create_right_hand_side applies the periodic BCs to its input and projects its output, so the ghost refresh between stages is redundant — the state's ghost entries go stale but are never read, and are stripped at the end. Bitwise-identical interiors; ~19% off the non-mutating rollout time plus fewer Zygote tape nodes.

Not changed

  • The channel/obstacle code paths (excluded from all experiments) are untouched.
  • Zygote pin (0.7.10) and Julia version (1.11.3) unchanged; INS.jl v5.0.0 requires only Julia ≥ 1.10.
  • Time integration stays RK4. A 3-stage third-order scheme would shave ~25% off both passes; that is a numerics/tuning decision best made separately — happy to send it as a follow-up if wanted.

Validation

Run against INS.jl v5.0.0 outside the container (calling the ns_solver.jl functions directly), 2-D (N=32) and 3-D (N=16):

  • in-place apply vs non-mutating VJP-primal rollout: rel. diff 2.2e-4 (2-D) / 1.0e-4 (3-D) — Float32 round-off accumulation (the same A/B in Float64 gives ≤4e-14);
  • grad_v0 (random direction) and grad_dt vs central finite differences: rel. err 1.1e-3–2.5e-3 (Float32 FD noise floor);
  • grad_nu vs central FD: 3e-2 at ε=1% shrinking monotonically to 5.4e-3 at ε=20% — the FD is noise-limited by Float32 loss quantization (identical FD values at ε=5% and 10%) and converges toward the AD value; the upstream rrule is additionally verified exactly (linear in ν) with ChainRulesTestUtils;
  • TGV kinetic-energy decay 0.9562 vs analytic 0.9608 at N=64/20 steps (the known ~2.4e-3 spatial + interpolation error of this solver in the baseline);
  • no silent promotion: every array in forward and pullback is Float32.

Expected effects of the benchmark:solver re-run: forward times ↓ ~4–6× plus the float32 gain, VJP times ↓ ~25–36% plus the float32 gain, FD-gradient errors move to the float32 floor, physics/agreement results unchanged within tolerance.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gdif1Cu1pdCJENe2hNUaF1

- Pin IncompressibleNavierStokes 5.0.0 (was 4.0.0).
- ns_vjp: take grad_nu from the same Zygote pullback as grad_v0/grad_dt;
  v5's diffusion rrule provides the viscosity cotangent (exact, the term
  is linear in nu). Removes the finite-difference fallback and its two
  extra forward rollouts per VJP call (~25-36% of reported VJP time,
  which vjp_cost never requested).
- ns_apply: forward-only rollouts use the in-place solve_unsteady stepper
  (RK44, same per-stage BC + projection as the projected RHS); 4-6x
  faster than the non-mutating AD-ready rollout at identical interiors.
  The VJP primal keeps the non-mutating path.
- Build the Setup from Float32 coordinates and make the collocated<->
  staggered helpers eltype-preserving. Previously the Float64 grid (and a
  bare 0.5 literal) silently promoted the state and the Zygote tape to
  Float64 after the first projection, despite Float32 casts at every
  interface; internal_dtype now truthfully float32.
- Drop the redundant per-stage ghost wrapping in the RK4 rollout:
  create_right_hand_side applies periodic BCs to its input and projects
  its output, so the refresh was dead work (~19% of the rollout plus
  Zygote tape nodes); interiors are bitwise identical.

Validated against v5.0.0 in 2D and 3D: in-place vs non-mutating forward
agree to Float32 round-off (2e-4; 4e-14 in a Float64 A/B), grad_v0 and
grad_dt match central FD to ~1e-3, grad_nu converges to the AD value as
the FD step grows (noise-limited by Float32 loss quantization), and TGV
kinetic-energy decay matches the analytic rate at the solver's known
spatial error. Channel/obstacle code paths (excluded from experiments)
are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gdif1Cu1pdCJENe2hNUaF1
@PasteurBot

PasteurBot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

CLA signatures confirmed

All contributors have signed the Contributor License Agreement.
Posted by the CLA Assistant Lite bot.

@agdestein

Copy link
Copy Markdown
Contributor Author

@PasteurBot recheck

@dionhaefner dionhaefner added the benchmark:solver Benchmark only the modified solver label Jul 10, 2026
dionhaefner added a commit that referenced this pull request Jul 20, 2026
Following up on the invitation in
agdestein/IncompressibleNavierStokes.jl#197 — thanks for including
INS.jl! This first PR only corrects a few statements about the solver;
the companion upgrade PR is #103.

Suggested label: `benchmark:none` — strings and metadata only, no
behavior change.

## Changes

- **`INS_JL_NO_OBSTACLE` exclusion text.** The current text says the
cylinder "cannot be represented in INS.jl" and that its "spectral/LU
pressure projection is also periodic-only". Neither is accurate:
- `psolver_direct` (LU) assembles the pressure Laplacian for any
supported BC combination — it is the general-BC fallback that
`default_psolver` selects for non-periodic setups. `psolver_transform`
(DCT) supports Dirichlet BCs, as this repo's own channel code uses. Only
`psolver_spectral` (FFT) is periodic-only.
- Brinkman volume penalization is a momentum forcing term, and a
*finite* penalization `−(χ/η)·u` added to the RHS before projection is
smooth and stable. What diverges is this tesseract's *hard-zero masking
after each projection* (an infinite-penalization clamp the projection
cannot see). INS.jl also natively supports the channel configuration
(`DirichletBC(f)` inflow, `PressureBC` outflow, periodic lateral BCs).

The exclusion itself stays (that route isn't implemented in the
tesseract); only the attribution is corrected.
- **`discretization: FD` → `FV`.** INS.jl is a staggered finite-volume
(energy-conserving) discretization; `FV` is among the documented options
for this field.
- **"CPU-only" description.** The wrapper runs on CPU, but the library's
kernels are KernelAbstractions.jl and run on CUDA (including the
spectral pressure solver and the differentiable path). Reworded so
readers don't take the cost tables as a solver property; `uses_gpu:
false` is kept as a property of this tesseract.

No results change; the generated solver/results pages pick these up on
the next docs build.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01Gdif1Cu1pdCJENe2hNUaF1

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Dion Häfner <dion.haefner@simulation.science>
dionhaefner added a commit that referenced this pull request Jul 21, 2026
Fork PRs (e.g. #103) fail benchmark CI because they cannot access the
private mosaic/* GHCR packages: the read-only fork token is denied
`packages: write` (build push fails) and cannot read the private package
either (every image pull returns `manifest unknown`). Both the changed
solver's head-SHA image and the unchanged solvers' base-SHA images are
unreachable, so the benchmark run jobs fail with "no image found".

Split benchmark CI along a trust boundary, mirroring the existing
docs-publish workflow_run pattern:

- benchmark.yml (on pull_request, fork context, read-only, no secrets)
now only plans and hands off. `plan-gate` (renamed from the old
`bench-ok` merge-gate job) enforces the label policy and seeds the
`bench-ok` commit status: failure on a policy violation, immediate
success when nothing will run, else pending. Seeds are best-effort so a
fork token that can't write a status doesn't cascade. A new `handoff`
job uploads the validated plan outputs as an artifact.

- benchmark-execute.yml (new, on workflow_run of "Benchmark", trusted
base-repo context) recovers + validates the handoff, then builds and
pushes the changed solver image to private GHCR, runs the benchmarks
(pulling the private images), renders the docs preview, and posts the
authoritative `bench-ok` commit status plus the PR comment / RTD
preview. Its `finalize` job always resolves a seeded pending — posting
`error` if the handoff can't be recovered on a successful planner run —
so the gate can never deadlock in `pending`. The status step is gated on
the planner having concluded success, so it never clobbers a correct
terminal status set by plan-gate.

- build-tesseracts.yml gains `ref`/`image_sha` inputs so the trusted run
builds the PR's HEAD and tags the image to match what the benchmark run
pulls; the fork-tolerant `continue-on-error` push is removed (both
remaining callers hold packages:write).

- docs-publish.yml is retired: workflow_run cannot chain, so its RTD +
PR-comment steps fold into benchmark-execute's finalize job.

The reusable build/benchmark calls drop `secrets: inherit`: the jobs
that build and run fork-authored solver code see only the ephemeral
GITHUB_TOKEN, keeping RTD_TOKEN / the bot PAT out of reach.

Branch protection keeps requiring the `bench-ok` context unchanged — it
is now a commit status rather than a job, posted only by the two paths
above, so a PR cannot merge until real benchmarks pass. workflow_run
changes only take effect once on main, so the fork path must be verified
by re-running a fork PR after merge.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@PasteurBot

Copy link
Copy Markdown
Contributor

📊 View the full benchmark results

The rendered docs preview has every plot for this run (forward accuracy,
gradients, cost, optimization) merged with existing baseline results on main. The summary below reports pass/fail status.


Status diff vs base

Legend · ✅ ok · 🟠 anom · ❌ fail · · missing · 🚫 excluded (permanent — out of score denominator) · ⚪ excluded (work-to-do) · * stale — result predates current benchmark run

No status changes. · score 0.98 → 0.95

Full Mosaic status

Mosaic status

Legend · ✅ ok · 🟠 anom · ❌ fail · · missing · 🚫 excluded (permanent — out of score denominator) · ⚪ excluded (work-to-do) · * stale — result predates current benchmark run

Each solver is run against every experiment in the suite. ok = produced valid results; fail = crashed or returned invalid data; anom = ran successfully but tripped an automated quality check (e.g. poor gradient accuracy, outlier wall-clock time, or diverged optimisation). Thresholds are defined per-problem in the problem config.

problem ok anom fail missing excl (work) excl (perm) stale score
ns-3d-grid 87 0 0 0 0 9 16 🟢 0.95
ns-grid 100 7 0 0 0 16 17 🟢 0.93
structural-mesh 45 0 0 0 0 5 0 🟢 1.00
thermal-mesh 55 0 0 0 0 7 8 🟢 0.96
overall 287 7 0 0 0 37 41 🟢 0.95

Failures & anomalies

  • 🟠 ns-grid · forward/agreement/tgv · PhiFlow — phiflow's double CenteredGrid↔StaggeredGrid resampling gives 4.18% amplitude damping (ratio=0.9582); cosine=0.9999924 (pattern correct); arithmetic-average output conversion fix worsened error 9×; upstream library change required
  • 🟠 ns-grid · forward/agreement/tgv · XLB — automatic k=9 sub-steps reduce Ma 0.88→0.098 (81× Ma² reduction); errors drop from 0.216-0.278 → 0.026-0.031 (11-24× peers); remaining floor is O(dx²) LBM spatial discretization at N=64, not reducible by further sub-stepping (tested k=9..27); valid=True
  • 🟠 ns-grid · forward/baseline · INS.jl — staggered MAC grid double-interpolation: collocated TGV IC -> staggered faces -> collocated output gives sin^2(pi/N) round-trip error at all N; 35-40x above collocated peers
  • 🟠 ns-grid · forward/baseline · jax-cfd — staggered MAC grid double-interpolation: collocated TGV IC -> staggered faces -> collocated output gives sin^2(pi/N) round-trip error at all N; 35-40x above collocated peers
  • 🟠 ns-grid · forward/baseline · XLB — irreducible O(Ma²) LBM compressibility error floor: at fixed dt=0.01, Ma=u·dt/dx grows with N; at N=128 Ma~0.2 giving ~0.007 error floor (230× peers); anomalous at all N
  • 🟠 ns-grid · forward/tgv_nu_sweep · XLB — same root cause as forward/agreement/tgv — automatic k=9 sub-stepping reduces Ma 0.88→0.098 but residual O(dx²) LBM spatial discretization gives 11-24× peer errors at all nu values (0.0001–0.05); 0.0309 at nu=0.05 is 12.0× peer median; not reducible by further sub-stepping (tested k=9..27); valid=True
  • 🟠 ns-grid · cost/temporal_cost · PhiFlow — median time 11.6s is 24× peer median (0.47s); threshold k=20.0
ns-3d-grid — 16 experiment(s)
experiment Exponax INS.jl OpenFOAM PhiFlow PICT Warp-NS XLB
forward/agreement ✅*
forward/baseline ✅*
forward/physical_laws/vs_N ✅*
forward/physical_laws/vs_nu ✅*
forward/physical_laws/vs_steps ✅*
cost/spatial_cost ✅*
cost/temporal_cost ✅*
cost/vjp_cost/by_N ✅* 🚫
cost/vjp_cost/by_steps ✅* 🚫
gradient/fd_check ✅* 🚫
gradient/horizon_sweep_limits ✅* 🚫
gradient/jacobian_svd ✅* 🚫
gradient/jacobian_svd_nu01 ✅* 🚫
gradient/jacobian_svd_steps20 ✅* 🚫
gradient/jacobian_svd_steps40 ✅* 🚫
optimization/recovery_constant_ic_bfgs_proj ✅* 🚫
ns-grid — 20 experiment(s)
experiment INS.jl jax-cfd OpenFOAM PhiFlow PICT Warp-NS XLB
forward/agreement/multimode ✅*
forward/agreement/tgv ✅* 🟠 🟠
forward/baseline 🟠 🟠 🟠
forward/cylinder 🚫 🚫 🚫
forward/physical_laws/vs_N ✅*
forward/physical_laws/vs_nu ✅*
forward/physical_laws/vs_steps ✅*
forward/tgv_nu_sweep ✅* 🟠
cost/spatial_cost ✅*
cost/temporal_cost ✅* 🟠
cost/vjp_cost/by_N ✅* 🚫
cost/vjp_cost/by_steps ✅* 🚫
gradient/fd_check ✅* 🚫
gradient/horizon_sweep ✅* 🚫
gradient/jacobian_svd ✅* 🚫
gradient/jacobian_svd_nu01 ✅* 🚫
gradient/jacobian_svd_steps20 ✅* 🚫
gradient/jacobian_svd_steps40 ✅* 🚫
gradient/param_sweep ✅* 🚫
optimization/drag_opt 🚫 🚫 🚫 🚫
structural-mesh — 10 experiment(s)
experiment deal.II FEniCS Firedrake JAX-FEM TopOpt.jl
forward/agreement
forward/baseline
forward/physical_laws
cost/spatial_cost
cost/temporal_cost
cost/vjp_cost/by_N 🚫
cost/vjp_cost/by_steps 🚫
gradient/fd_check 🚫
gradient/param_sweep 🚫
optimization/topopt 🚫
thermal-mesh — 14 experiment(s)
experiment deal.II FEniCS Firedrake JAX-FEM torch-fem
forward/agreement
forward/baseline
forward/physical_laws
forward/source_baseline
forward/source_linearity
cost/spatial_cost
cost/temporal_cost
cost/vjp_cost/by_N 🚫
cost/vjp_cost/by_steps 🚫
gradient/fd_check 🚫 ✅* ✅*
gradient/param_sweep 🚫 ✅* ✅*
gradient/source_fd_check 🚫 ✅* ✅*
gradient/source_width_sweep 🚫 ✅* ✅*
optimization/conductivity_recovery_bfgs 🚫

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

benchmark:solver Benchmark only the modified solver

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants