perf: upgrade INS.jl tesseract to v5.0.0 (native ν gradient, in-place forward, float32)#103
perf: upgrade INS.jl tesseract to v5.0.0 (native ν gradient, in-place forward, float32)#103agdestein wants to merge 8 commits into
Conversation
- 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
CLA signatures confirmedAll contributors have signed the Contributor License Agreement. |
|
@PasteurBot recheck |
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>
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>
📊 View the full benchmark resultsThe rendered docs preview has every plot for this run (forward accuracy, Status diff vs baseLegend · ✅ ok · 🟠 anom · ❌ fail · · missing · 🚫 excluded (permanent — out of score denominator) · ⚪ excluded (work-to-do) · * stale — result predates current benchmark run 4 regression(s) · 1 improvement(s) · 0 other transition(s) · 0 new row(s) · 0 removed row(s) · score 0.98 → 0.94 🔴 Regressions
🟢 Improvements
Full Mosaic statusMosaic statusLegend · ✅ 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.
Failures & anomalies
ns-3d-grid — 16 experiment(s)
ns-grid — 20 experiment(s)
structural-mesh — 10 experiment(s)
thermal-mesh — 14 experiment(s)
|
## Summary #113 broke `main`: its trusted `workflow_run` executor checked out the PR's fork code (`ref: <head SHA>`) to build it, but `actions/checkout@v6` refuses a fork PR checkout in a `workflow_run` context by default (the "pwn request" guard), so the build job failed for every solver-changing PR. Opting in (`allow-unsafe-pr-checkout: true`) would run fork-authored `build_base.sh`/Dockerfile with the base repo's `packages: write` token — exactly the vulnerability the guard exists to prevent. This PR **reverts #113** and replaces it with a much smaller, fork-safe flow that never executes fork code in a trusted context and never requires a fork to push to (or privately read from) GHCR. ## How it works now Everything stays on the `pull_request` trigger: - **Build (PR path):** builds the changed solver, `docker save`s it, and uploads a `solver-image-<name>` artifact instead of pushing to GHCR. The GHCR push path is retained only for push-to-main / dispatch (which publishes the baseline `:latest` / `:<sha>` images). - **Run:** `docker load`s those artifacts back to `<name>:latest` — the exact local tag `mosaic run --no-build` resolves — then pulls only the *unchanged* solvers from **public** GHCR at the PR's base SHA, skipping the loaded ones (`pull-solver-images.py --skip-images`). - `bench-ok` stays a normal job-based merge gate (**no branch-protection change**). Docs RTD/preview + PR comment continue via the existing `docs-publish.yml` (`workflow_run`). Fork code only ever runs in the untrusted `pull_request` context (read-only token, no secrets) — the build produces an artifact, the run only consumes images — so no trusted `workflow_run`, commit-status gating, or handoff is needed. ## Required setup (done) The `mosaic/*` GHCR packages must be **public-read** so fork PRs can pull base images and warm the build `cache-from`. This has been flipped to public. Write stays maintainer-only (unchanged). ## Verification - `actionlint` clean (only pre-existing legacy shellcheck/`head.ref` nits, none in new code). - Local proof of the tag seam: `docker save <name>:latest` → `docker load` reproduces exactly `<name>:latest`. - Two rounds of adversarial review; the second confirmed the tag chain against `config.py`/`runner.py` and found no correctness bugs. - `workflow_run`/artifact behavior can only be fully exercised once on `main`; after merge, re-run a fork PR (e.g. #103) to confirm end-to-end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Seems like I finally fixed CI for forks 💦 @agdestein Does the new INS.jl Tesseract build for you locally? Seeing failures like this in the log: This hints at a permissions error in |
`import juliacall` resolves juliacall's Julia environment on first use:
juliapkg adds PythonCall to /app/julia_env, precompiles it into
/app/julia_depot, and guards all of it with a lock file at
/app/julia_env/lock.pid (mode 644).
Left to itself, that first import is Tesseract's own `tesseract-runtime
check`, which the generated Dockerfile runs as uid 9999 *after* every
custom build step -- so the lock file and ~700 depot entries end up owned
by 9999, past the reach of our `chmod -R 777 /app`. `tesseract serve`
starts the container as the *host* uid (1001 on the runner, 1000 on a
laptop), which then cannot open the lock file:
PermissionError: [Errno 13] Permission denied: '/app/julia_env/lock.pid'
The API import fails, and every call reports "Tesseract container ...
failed to start". The build-time check itself passes, because it is the
one context that runs as 9999 -- so the image builds green and is dead on
arrival. Not specific to v5.0.0: this bites any change that invalidates
the cached check layer, and topopt-jl has the same latent shape.
Do the warm-up as root instead, mirroring the init in tesseract_api.py,
and chmod in the same RUN (a separate layer would copy up every file the
warm-up just created, +130 MB). The build-time check then finds the
environment resolved and juliapkg skips straight past it: nothing under
/app is left owned by 9999, and the check layer drops from 134 MB to
1.4 MB.
Verified locally by building both ways: as uid 1000 the old image fails
with the traceback above and `Tesseract.from_image()` raises "Tesseract
failed to start", while the new image passes `check` as uid 1000/4321/9999
and completes apply + vector_jacobian_product unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…art it
Same defect as the ins-jl tesseract in the previous commit, and the same
one-line shape of fix. juliacall resolves its Julia environment on first
import, taking a lock at /app/julia_env/lock.pid (mode 644) and
precompiling the PythonCall extensions into /app/julia_depot. That first
import is Tesseract's `tesseract-runtime check`, which the generated
Dockerfile runs as uid 9999 after every custom build step -- past our
`chmod -R 777 /app` -- while `tesseract serve` starts the container as the
host uid, which then cannot open the lock file.
This image is currently alive only because nothing has invalidated its
cached check layer; any change to this tesseract would have shipped a
container that dies on every call with
PermissionError: [Errno 13] Permission denied: '/app/julia_env/lock.pid'
Verified locally by building both ways: the current config produces an
image whose `check` fails as uid 1000 with the traceback above, with 203
files under /app owned by 9999 (including the SciMLBase/MLCore PythonCall
extension caches), and `Tesseract.from_image()` raises "Tesseract failed
to start". With this commit: 1 file (Pkg's usage log), `check` passes as
uid 1000/4321/9999, and apply returns compliance=62957.645. Check layer
38.7 MB -> 1.32 MB; image 4.35 GB -> 4.33 GB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Yes, it reproduces locally, and it turns out not to be specific to v5.0.0. Root cause. That "first import" is Tesseract's own sanity check, At runtime, The image builds green because the build-time check is the one context that runs as 9999, so it Fix: do the warm-up as root in a build step that mirrors the API's Julia init, and chmod in A/B locally (built both ways, then
TopOpt.jl has the same defectWorth flagging since it's the other juliacall tesseract: I've included the same one-line fix for it here. After it: 1 file left, The |
`mosaic run -s "INS.jl,TopOpt.jl" -p ns-grid` exits 1 with
"Unknown solver 'TopOpt.jl'" even though the name is perfectly valid --
it just belongs to structural-mesh.
The flat -s CSV is a union set: _apply_solver_filter applies it per
problem and documents that "a name that doesn't exist on the current
problem is fine (it presumably belongs to another problem)", leaving typo
detection to the up-front check. But that check scoped itself to the
problems in -p, so the two disagree the moment a run is sharded by
problem while -s carries the full cross-problem set -- which is exactly
what the benchmark workflow does: one job per (suite, problem), each
invoked with `--solvers "<every changed solver>"`. A PR touching
tesseracts in two problem domains therefore fails *every* benchmark job
before doing any work, in both directions (ns-grid rejects TopOpt.jl,
structural-mesh rejects INS.jl).
Validate against every registered problem instead, which is what the
function's own docstring already claimed ("must exist on at least one
problem"). A genuine typo still fails fast, and its "did you mean"
suggestion now draws on the full solver list rather than only the
problems being run. `problem_list` is no longer needed, so drop it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Heads-up on the red benchmark jobs — that one isn't the tesseracts, it's a harness bug this PR happens to be the first to trigger. Both images build fine now; every job then dies up-front with
The check contradicts the filter it guards: Worth noting the workflow-side alternative doesn't work as-is: Local checks on the fix: full test suite 478 passed, ruff clean, and all three sharded invocations ( |
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 theins_jlcells.Changes that affect published numbers
INS.jl v5.0.0's
diffusionrrule provides the ν cotangent (the diffusive term is linear in ν, so it is exact).ns_vjpnow takesgrad_v0,grad_dt, andgrad_nufrom a single Zygote pullback. This removes two full forward rollouts from every VJP call — the FD fallback ran unconditionally, even thoughvjp_cost(jax.gradw.r.t.v0) never requested the ν gradient, inflating all reported INS.jl VJP times by ~25–36%.solve_unsteadyinstead of the AD-ready rollout.applyhas 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.internal_dtype: float64 → float32).Previously the
Setupwas 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 theFloat32casts at every interface: Float64 compute cost with Float32 I/O precision. (A bare0.5literal 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
LinRangechange — happy to go either way.)Changes with identical results (pure speedup)
add_ghosts/strip_ghostswrapping in the RK4 rollout used for the VJP primal.create_right_hand_sideapplies 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
Validation
Run against INS.jl v5.0.0 outside the container (calling the
ns_solver.jlfunctions directly), 2-D (N=32) and 3-D (N=16):applyvs 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) andgrad_dtvs central finite differences: rel. err 1.1e-3–2.5e-3 (Float32 FD noise floor);grad_nuvs 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;Expected effects of the
benchmark:solverre-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