Skip to content

[codex] Add mesh vector calculus functionals#1735

Open
loliverhennigh wants to merge 13 commits into
NVIDIA:mainfrom
loliverhennigh:mesh-vector-calculus-functionals
Open

[codex] Add mesh vector calculus functionals#1735
loliverhennigh wants to merge 13 commits into
NVIDIA:mainfrom
loliverhennigh:mesh-vector-calculus-functionals

Conversation

@loliverhennigh

@loliverhennigh loliverhennigh commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add FunctionSpec-backed mesh LSQ divergence, curl, and double-LSQ Laplacian functionals
  • add FunctionSpec-backed cotangent/DEC Laplacian and divergence functionals with Torch references and Warp custom kernels
  • consistently expose and forward implementation from mesh-level functional callers, defaulting mesh APIs to "torch" (None retains KNN auto-dispatch)
  • register the five new functionals with the benchmark registry
  • split the combined mesh vector-calculus tests into one module per functional
  • document each new functional with five reproducible PNG examples and a checked-in generator

Validation

  • changed-file pre-commit suite (Ruff, format, interrogate, license, large-file, import-linter)
  • pytest -q test/nn/functional/derivatives test/mesh/calculus — 317 passed, 11 skipped
  • focused mesh dispatch/sampling/remeshing and split-functional suite — 120 passed, 16 skipped
  • all 40 new CPU benchmark plan entries executed successfully across Torch/Warp forward and backward cases
  • documentation figure generation is byte-for-byte reproducible; all five images are 1980×774 RGB PNGs
  • GitHub Pre-commit, pre-commit.ci, and Install CI all pass

Notes

  • the local environment did not expose CUDA, so GPU-specific validation remains delegated to CI/GPU environments

@copy-pr-bot

copy-pr-bot Bot commented Jun 12, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@loliverhennigh loliverhennigh force-pushed the mesh-vector-calculus-functionals branch from e301081 to f820150 Compare June 25, 2026 18:23
@loliverhennigh loliverhennigh force-pushed the mesh-vector-calculus-functionals branch from 208bcce to 28b889f Compare June 30, 2026 20:59
@loliverhennigh loliverhennigh marked this pull request as ready for review June 30, 2026 21:00
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds five new FunctionSpec-backed mesh vector calculus functionals — LSQ divergence, curl, and double-LSQ Laplacian, plus cotangent/DEC Laplacian and divergence — each with eager PyTorch and Warp backends. It then wires Mesh.divergence, Mesh.curl, and the expanded Mesh.laplacian (new method and implementation parameters) through these functionals while preserving default Torch precision for backwards compatibility.

  • Five new functional packages under physicsnemo/nn/functional/derivatives/mesh_lsq_* and mesh_cotan_*, each following the existing FunctionSpec registration pattern with proper custom-op autograd for the Warp kernels.
  • Mesh.laplacian gains a method parameter ("cotan" / "dec" / "lsq") and data_source="cells" support for the LSQ path; all four high-level mesh calculus methods now accept an implementation pass-through argument.
  • New focused parity tests validate affine-field ground truths and cross-backend forward/backward consistency for all five functionals.

Important Files Changed

Filename Overview
physicsnemo/nn/functional/derivatives/_mesh_cotan_operator_utils.py New shared utilities for cotangent mesh operators: validation, normalization, and test-case generators. Defines a local safe_eps that is identical to but separate from the one in physicsnemo.mesh.utilities._tolerances.
physicsnemo/nn/functional/derivatives/_mesh_lsq_operator_utils.py New shared utilities for LSQ mesh operators: CSR geometry validation, scalar/vector field validators, and deterministic KNN CSR test-case builder. Logic is correct and well-guarded.
physicsnemo/nn/functional/derivatives/mesh_cotan_divergence/_warp_impl.py Warp custom-op implementation for cotangent divergence with explicit backward pass. Autograd context saves and restores dtype correctly; backward gradient derivations are mathematically correct.
physicsnemo/nn/functional/derivatives/mesh_cotan_laplacian/_warp_impl.py Warp custom-op for cotangent Laplacian; per-component accumulation kernel and explicit backward are correct. Flatten/reshape pattern handles scalar and vector values consistently.
physicsnemo/nn/functional/derivatives/mesh_lsq_divergence/_warp_impl.py Warp divergence delegates to mesh_lsq_gradient_warp then applies torch.diagonal(...).sum(-1). Correct mathematically, but runs input validation twice (once here, once inside the gradient call).
physicsnemo/mesh/mesh.py Adds implementation parameter to gradient, divergence, curl, and laplacian methods, and adds method parameter to laplacian. All new match branches and NotImplementedError paths look correct; backwards compatibility is preserved.
physicsnemo/mesh/calculus/divergence.py Rewires point/cell divergence functions to dispatch through new functionals. Points variant exposes min_neighbors; cell variant does not, creating a minor API asymmetry.
test/nn/functional/derivatives/test_mesh_vector_calculus_functionals.py Functional parity tests covering affine fields, simple chain cases, and backend forward/backward parity. Test values are hand-verified correct; warp-dependent tests are properly guarded.
test/mesh/calculus/test_mesh_vector_functional_wiring.py Mesh-API wiring tests validate that Mesh.gradient/divergence/laplacian with implementation='torch' routes correctly to the calculus-layer functions, and that intrinsic Warp raises NotImplementedError as documented.

Comments Outside Diff (2)

  1. physicsnemo/nn/functional/derivatives/_mesh_cotan_operator_utils.py, line 622-625 (link)

    P2 safe_eps redefined locally instead of imported from shared module

    safe_eps is already defined in physicsnemo.mesh.utilities._tolerances with an identical implementation. Defining it again here means future changes to one copy won't propagate to the other. The _tolerances.py version even has thorough documentation of the float16/bfloat16/float32/float64 values.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  2. physicsnemo/mesh/calculus/divergence.py, line 186-216 (link)

    P2 min_neighbors absent from cell-level variant but present in points-level variant

    compute_divergence_points_lsq accepts min_neighbors, but compute_divergence_cells_lsq does not — it hardcodes min_neighbors=0. The same asymmetry appears in the curl and Laplacian cell variants. Callers who need to skip poorly-connected cell centroids have no way to configure this threshold for cell-based operations, even though the underlying mesh_lsq_divergence functional supports it.

Reviews (1): Last reviewed commit: "Keep mesh calculus tolerances inside fun..." | Re-trigger Greptile

Remove cross-backend operator utilities, fix cotangent edge cases, expose the complete Mesh API, and merge current upstream main.
@loliverhennigh

loliverhennigh commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

ORD A100-SXM4-80GB update: the original LSQ results still hold (3.36–3.90x faster through Mesh at 65k; 27–55x faster for isolated 262k training steps).

Cotangent follow-up job 30030777 passed all 18 finite/output/gradient parity records (max relative L2 8.9e-7). Geometry caching makes warm public calls 8–10x faster at 65k and 19–24x faster at 262k versus the focused ablation. Warp divergence is now 1.12–1.13x faster through Mesh and up to 2.76x faster for 262k all-gradient functional training. Warp Laplacian functional forward is about 2x faster than the prior Warp path, but remains about 9% behind Torch; field-only training crosses over at 262k while all-input training remains about 25% slower.

Torch remains the default solely for backwards compatibility.

def partition_cells(
mesh: Mesh,
seeds: Float[torch.Tensor, "n_seeds n_spatial_dims"],
implementation: Literal["cuml", "torch", "scipy"] | None = "torch",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[blocking] Defaulting to implementation="torch" here is a scalability regression. Previously this passed None to knn, which auto-dispatches to cuML (GPU) or scipy KDTree (CPU). The torch backend materializes the full (M, N, D) broadcast tensor (physicsnemo/nn/functional/neighbors/knn/_torch_impl.py:45), so memory goes from O(M+N) to O(M·N·D).

Concretely: partition_cells is called by the GLOBE DrivAer pipeline (examples/cfd/external_aerodynamics/globe/drivaer/dataset.py:284) with seeds = boundary.cell_centroids. At DrivAerML scale (~1e5 boundary cells × ~1e6 volume cells × 3 dims × 4 B) that intermediate is O(TB) — an instant OOM where the code works today, and a silent behavior change to a public API.

The determinism rationale is also weaker here than for the calculus functionals: a k=1 NN query returns the same result on every backend modulo distance ties, unlike floating-point reduction order in the derivative kernels. Suggest keeping None (auto-dispatch) as the default for the KNN-backed entry points and letting callers opt into "torch" explicitly.

def find_nearest_cells(
mesh: "Mesh",
query_points: Float[torch.Tensor, "n_queries n_spatial_dims"],
implementation: Literal["cuml", "torch", "scipy"] | None = "torch",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[blocking] Same concern as partition_cells: this default change (also at match_points, sample_data_at_points, and Mesh.sample_data_at_points) switches existing callers from KNN auto-dispatch to the brute-force torch backend, which allocates an (n_cells, n_queries, dims) intermediate. For find_nearest_cells on a production-size mesh this OOMs where the scipy/cuML paths were fine. Suggest defaulting these five entry points back to None.

Comment thread physicsnemo/mesh/calculus/curl.py Outdated
)


def _curl_from_jacobian(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_curl_from_jacobian is now dead code — this PR removed both call sites (points and cells now go through mesh_lsq_curl), and nothing in physicsnemo/ or test/ references it anymore. Safe to delete. (Its sibling _apply_cotan_laplacian_operator in laplacian.py is still used by mesh/curvature/_laplacian.py:99, so that one should stay.)

import torch


def _safe_eps(dtype: torch.dtype) -> float:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_safe_eps and the ~70-line _validate_inputs are duplicated verbatim between _torch_impl.py and _warp_impl.py in both cotan packages (4 copies of the safe-eps formula, plus a 5th inline in mesh_cotan_divergence.py's _make_benchmark_case). The layering contract legitimately forbids importing mesh.utilities._tolerances.safe_eps from the functional layer, but the existing convention in mesh_lsq_gradient/ is a package-local shared utils.py — following that here would keep these from drifting. This formula encodes a subtle float16 cap that would be easy to fix in one copy and miss in the others.

values: torch.Tensor,
) -> torch.Tensor:
"""Apply the normalized cotangent Laplacian with Warp kernels."""
_validate_inputs(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Validation runs twice on this path: once here in the wrapper and again inside mesh_cotan_laplacian_impl (line 187). The divergence op validates only inside the custom op — inconsistent between the two. Each _validate_inputs call does edges.min().item() / edges.max().item(), i.e. two host-device syncs per validation on CUDA (and a graph break under torch.compile). Note the DEC Mesh.laplacian/Mesh.divergence paths previously ran sync-free inline code, so this is a new per-call cost on the default torch path too. Deduplicating so validation runs once per public call would recover most of it.

Comment thread physicsnemo/mesh/mesh.py Outdated
case _:
raise ValueError(
f"Invalid {data_source=!r}. Must be 'points' or 'cells'."
f"Invalid {method=!r} (must be 'cotan', 'dec', or 'lsq') "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this catch-all reports "Invalid method=... or data_source=..." without distinguishing which one is wrong. Splitting the two checks would give crisper errors.

shape ``(n_entities, 3)`` in 3D.
"""

_COMPARE_ATOL = 8e-3

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: 8e-3 abs+rel is loose enough to hide real backend divergence (the cotan specs use 5e-6). Presumably this absorbs fp32 lstsq-vs-Warp-QR noise from the underlying LSQ solve — a one-line comment justifying the magnitude would help future readers, and applies to the divergence/laplacian specs too.

Comment thread physicsnemo/mesh/mesh.py Outdated
to ``"torch"`` for backwards-compatible numerical precision. Pass
``"warp"`` to use the accelerated ambient LSQ gradient kernels.
Intrinsic tangent-space LSQ gradients use their existing Torch
implementation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This last sentence reads as a silent fallback, but the actual behavior when passing implementation="warp" with an intrinsic gradient on a codim > 0 mesh is a NotImplementedError (raised in compute_gradient_points_lsq, and covered by test_intrinsic_point_gradient_rejects_warp_implementation). The raise is the right behavior — silent fallback would hide intent — so the docstring should describe it, e.g. "requesting \"warp\" for intrinsic tangent-space gradients raises NotImplementedError; use gradient_type=\"extrinsic\" or implementation=\"torch\"". The same misleading sentence appears in compute_point_derivatives (calculus/derivatives.py:88-89).

if intrinsic and mesh.codimension > 0:
if implementation not in (None, "torch"):
raise NotImplementedError(
"Warp implementation is not available for intrinsic tangent-space "

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This message suggests gradient_type='extrinsic', but gradient_type is a parameter of the callers (Mesh.gradient / compute_point_derivatives) — this function's own parameter is intrinsic: bool. A direct caller of compute_gradient_points_lsq gets pointed at a kwarg that doesn't exist here. Suggest phrasing in the local vocabulary (intrinsic=False or implementation='torch'), or mentioning both spellings.

laplacian = laplacian / dual_volumes_0.view(
-1, *([1] * (point_values.ndim - 1))
).clamp(min=safe_eps(dual_volumes_0.dtype))
def compute_laplacian_points_lsq(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two documentation gaps around the new LSQ Laplacians:

  1. This module's top docstring still describes only the intrinsic cotangent/DEC operator ("This is the cotangent Laplacian, intrinsic to the manifold"), but the module now also hosts two extrinsic double-LSQ operators.
  2. These two new public functions have ~3-line docstrings in a module whose house style is full NumPy-doc with the math written out (cf. compute_laplacian_points_dec just above). The double-LSQ operator in particular deserves a Notes section on its accuracy properties (boundary bias / noise amplification from composing two first-order reconstructions) — the old Mesh.laplacian docstring carried exactly that caveat ("a double-LSQ discretization with different accuracy properties") and it was dropped in the rewrite.

@peterdsharpe

Copy link
Copy Markdown
Collaborator

Top-level summary for my review above (GitHub dropped the review body on submit):

Thanks — the core contribution here is solid. I hand-verified the backward formulas for both cotan custom ops (including the clamped-volume gradient mask) and ran torch.autograd.gradcheck against the Warp op locally — all correct. Curl sign conventions survive the layout change (functional layout is (n, deriv, comp) vs. the old mesh layout (n, comp, deriv); both give the same curl), cell operators use the same codim-1 adjacency as before, the local _safe_eps copies are formula-identical to mesh.utilities._tolerances.safe_eps, and back-compat on the calculus APIs is genuinely preserved (the old code hardcoded the torch path, so implementation="torch" defaults are behavior-preserving there). Ran the affected suites locally on CPU+GPU: 800 passed, including warp↔torch forward/backward parity, empty meshes, and fp64 round-trips through the fp32 Warp kernels.

The functional decomposition is clean: the mesh layer computes geometry (cotan weights, dual volumes, CSR adjacency) and the functional layer does math on raw tensors, which respects the import-linter layering and makes the operators benchmarkable.

One blocking issue: the KNN-backed sampling APIs (partition_cells, find_nearest_cells, match_points, sample_data_at_points, Mesh.sample_data_at_points) flip their default from KNN auto-dispatch to the brute-force torch backend. That backend materializes an (M, N, D) broadcast tensor, so this silently changes existing public APIs from O((M+N) log M) to O(M·N) memory — pipelines like GLOBE DrivAer (examples/cfd/external_aerodynamics/globe/drivaer/dataset.py:284) that work today will OOM at scale. Details inline. Unlike the calculus functionals, the determinism rationale is weak for k=1 nearest-neighbor (deterministic modulo ties on every backend), so I'd keep None (auto-dispatch) as the default for these five entry points.

The rest is cleanup-level: dead _curl_from_jacobian, validation dedup, _safe_eps consolidation — fine to land in this PR or a fast-follow.

@peterdsharpe peterdsharpe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

All review feedback addressed — approving. Verified locally against the new head (CPU + GPU, 896 tests passing across test/mesh/calculus, test/mesh/sampling, test/mesh/remeshing, test/nn/functional/derivatives, and test/core/test_function_spec.py):

  • KNN defaults reverted to auto-dispatch on all five entry points (partition_cells, find_nearest_cells, match_points, sample_data_at_points, Mesh.sample_data_at_points) — this was my one blocking concern, and the forwarding tests were updated to match.
  • Dead _curl_from_jacobian removed; error messages now validate method/data_source/gradient_type up front and speak each function's own vocabulary; DEC gradients now raise NotImplementedError for implementation="warp" instead of silently ignoring it; docstrings (module-level Laplacian, intrinsic+warp behavior, double-LSQ accuracy caveats, parity tolerances) all brought up to standard; implementation=None auto-dispatch now has test coverage.
  • The new work since last review holds up under scrutiny. I re-derived the fused Warp backward math (the per-endpoint g[v0]/V0 − g[v1]/V1 form is equivalent to the previous normalize-then-scatter formulation), ran torch.autograd.gradcheck on all differentiable inputs of the new fused forward+backward custom ops, and confirmed double backward works through the differentiable torch fallback. The geometry/topology caching split (topology keyed on cells, geometry on points, cleared on geometric transforms, bypassed under requires_grad, inference-mode tensors handled) is correct, and I verified warm-call parity through the cache. The algebraic C = H·G⁻¹·Hᵀ rewrite in the cotan weights checks out block-by-block. Single-sync aminmax().tolist() validation is a nice touch.

Non-blocking follow-up (leaving the inline thread open): _safe_eps still has four copies across the cotan backends and _validate_inputs is duplicated per backend file. Now that the torch and Warp implementations have diverged substantially, per-package self-containment is more defensible than it was — but a package-local utils.py (as in mesh_lsq_gradient/) would still be the tidier home. Fine to defer to a later cleanup.

Nice work on this one — the Warp backward kernels and the caching layer in particular are careful, well-tested additions.

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