feat: add C++ backend for pdist_tangency_grad (batched pairwise tangency VJP) - #134
Conversation
…ncy VJP) Add a single-call batched C++ kernel that computes, over all C(N,2) pairs, the condensed tangency distances plus the per-pair gradient blocks dt_dp / dt_dq using the same envelope-theorem and implicit differentiation math as the Python reference. Expose it through the existing ctypes layer and auto-dispatch in ellphi.grad, mirroring how pdist_tangency selects the C++ backend. The pure-Python implementation stays as the fallback (also for stale libraries lacking the new export) and the VJP accumulation remains a NumPy scatter in Python. The public pdist_tangency_grad(coefs) -> (dists, vjp) API is unchanged. Closes #129 Co-authored-by: koki3070 <koki3070@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR adds a batched C++ backend for ellphi.grad.pdist_tangency_grad, exposing it through the existing ctypes layer and auto-dispatching from the public Python API to dramatically speed up pairwise tangency VJP computation while preserving the existing public signature and Python fallback behavior.
Changes:
- Add a new C++ export
pdist_tangency_gradthat computes condensed distances plus per-pair gradient blocks (dt_dp,dt_dq) in one call. - Update
ellphi.grad.pdist_tangency_gradto dispatch to the C++ kernel when available (with a Python reference fallback) and to use vectorizednp.add.ataccumulation for the VJP. - Add tests covering C++ vs Python numerical agreement, stale-library symbol probing, degenerate error cases, and fallback selection; update release notes and type stubs.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_grad_cpp.py |
Adds test coverage for the new C++ grad kernel, fallback behavior, and degenerate/stale-backend scenarios. |
src/ellphi/grad.py |
Adds Python reference helper and dispatches pdist_tangency_grad to C++ when available; refactors VJP accumulation to vectorized scatter. |
src/ellphi/_tangency_cpp.pyi |
Extends typing surface with has_pdist_tangency_grad and pdist_tangency_grad signatures. |
src/ellphi/_tangency_cpp.py |
Adds symbol probe and ctypes binding for pdist_tangency_grad, plus backend error mappings. |
src/ellphi/_tangency_cpp_impl.cpp |
Implements and exports the batched C++ kernel and per-pair gradient computation. |
RELEASE_NOTES.md |
Documents the new auto-dispatch behavior and performance improvement. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…rror Address Copilot review findings on #134: - The VJP returned by pdist_tangency_grad now validates its cotangent up front: grad_dists must be broadcastable to (N*(N-1)//2,) (a scalar broadcasts to all pairs); other shapes raise a clear ValueError naming the expected shape. The previous loop-based implementation accidentally accepted over-long 1-D arrays (silently truncated) and 2-D arrays (undocumented per-component semantics); both now fail explicitly. The contract is documented in the docstring and the annotation widened to numpy.typing.ArrayLike. - The internal ctypes binding _tangency_cpp.pdist_tangency_grad now raises a RuntimeError with the module's standard rebuild guidance when the loaded library is stale and lacks the new export, instead of a bare AttributeError. - New tests: valid 1-D, scalar, wrong-length, and 2-D cotangents, and the stale-library direct-call path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Addressed both Copilot review findings in 6eab7c7:
Local checks re-run: black / flake8 / mypy / stubtest / pytest (264 passed) all green. 🤖 Generated with Claude Code |
Downstream benchmark (TDA-ML prototype vs this PR)We compared our local prototype ( Environment: Linux x86_64 (AMD Ryzen Threadripper PRO 7995WX), Python 3.12.13, warmup=3 / repeats=10 (median reported). Speed (C++ kernel, N=120, d=2, 7140 pairs)
Also checked TDA-ML elongate workload (MNIST + local-PCA teacher, N=100): C++ 6.4–7.0 ms vs Python ~1.2 s (~175–193×). Accuracy (same
|
| case | Python (proto) | C++ (proto) | C++ (PR) |
|---|---|---|---|
| random_n10_d2 | 10.9 | 0.06 | 0.07 |
| random_n30_d2 | 106.2 | 0.55 | 0.61 |
| random_n50_d3 | 344.5 | 2.02 | 2.27 |
| eccentric_n30_d2 | 106.9 | 0.90 | 0.79 |
| tda_ml_elongate_n100 | 1222.9 | 6.96 | 6.38 |
|
@koki3070 ありがとう.random ケースだけ proto 版の方がほんの若干だけ速いみたいですが,ほかはそうでもないところだけ見るとこれはただの誤差ですかね? 今の PR 実装にだけ何かしらかのオーバーヘッドがあったりしないかだけ少し気になりました. オーバーヘッドがなにかあったとしても影響は軽微そうなので,様子を見てからマージします. |
Closes #129
Summary
Adds a single-call batched C++ kernel
pdist_tangency_gradto_tangency_cpp_impl.cppthat computes, over all C(N,2) pairs, the condensed tangency distances plus the per-pair gradient blocksdt_dp/dt_dq, using the same envelope-theorem + implicit-differentiation math as the Python reference (tangency_grad/solve_mu_gradients). It is exposed through the existing ctypes layer in_tangency_cpp.pyand auto-dispatched fromellphi.grad.pdist_tangency_grad.This follows the maintainer-approved answers to the issue's three questions:
pdist_tangencyC export.pdist_tangency— no new public API names and no separate build flag.pdist_tangency_grad(coefs) -> (dists, vjp)auto-selects the C++ kernel exactly when the C++ backend is available (same selection as the forward path), with an additional symbol probe (has_pdist_tangency_grad) so a stale cached library that predates the new export falls back to Python instead of failing.ellphi.grad._pdist_tangency_grad_python), and the VJP accumulation (np.add.atscatter) stays in NumPy/Python. C++ returns distances + per-pair gradient blocks only.Degenerate configurations (identical / concentric ellipsoids) keep raising
ZeroDivisionError, matching the Python reference (new error-message mappings in_raise_backend_error).Credit
The design and performance case come from the prototype by @koki3070 and collaborators (downstream project TDA-ML, local branch
feature/pdist-tangency-grad-cpp, commit9fcad0c). The prototype branch was not publicly accessible at implementation time, so this PR re-derives the kernel from the issue's description and the in-repo Python reference, matching this repository's C++/ctypes conventions. Thank you for the detailed issue and benchmarks!Measured results (local, macOS arm64, N=120, d=2, 7140 pairs)
Numerical agreement vs the Python reference on random ellipse clouds (multiple sizes, 2-D and 3-D, including highly eccentric near-degenerate ellipses): distances agree to
<= 1e-12abs; gradients agree with max relative error ~1.8e-11 (max absolute error ~2.3e-9 at N=120, occurring only on entries of magnitude ~1e2; the test-suite toleranceassert_allclose(rtol=1e-10, atol=1e-10)passes on all covered clouds).Timing is reported here only; tests do not hard-assert timing.
Implementation notes for reviewers
solve_mu,pencil_into,cholesky_factor_into,solve_with_cholesky_into,quad_eval), so it works in both build modes:ELLPHI_USE_EIGEN=1(verified: compiles and passes tests withcpp_linalg_kind='eigen') and the portable internal path (default, also verified).solve_mu_gradientsstep by step: pencil Cholesky, residual,dF/dmu = 2 r . K^{-1} r(with the samenumpy.isclosethreshold1e-8), centre-Jacobian rows-K^{-1} rhs_kcontracted againstphi_x = -2 r(implemented as2 (K^{-1} rhs_k) . r), then the envelope-theorem assembly with the1/(2t)factor. Reviewers may want to double-check this contraction identity and the degenerate-error mappings.TANGENCY_VERSION+ linalg kind inscripts/build_tangency_cpp.py) already rebuilds the library onuv syncbecause the C++ source changed; no version-marker change is needed. For any environment that still loads an older same-version library, the new symbol probe falls back to Python gracefully (covered by tests).(dists, dt_dp, dt_dq)arrays and both paths share one vectorisednp.add.atVJP (previously a per-pair Python loop closure).Verification (all run in this branch)
uv sync— OK (rebuilds backend)uv run python -m ellphi --build-info— OK (cpp_backend_available=True, checked with bothcpp_linalg_kind='internal'and'eigen')uv run black src tests scripts/uv run black --check src tests scripts— OKuv run flake8 src tests scripts— OKuv run mypy src tests— OK (no issues in 34 source files)MYPYPATH=src uv run stubtest ellphi --allowlist stubtest-allowlist.txt— OKuv run pytest— OK (259 passed; suite also re-run against the Eigen build)uv lock --check— OKuv run mkdocs build— OK (release-notes touch; pre-existing griffe warnings only)🤖 Generated with Claude Code
https://claude.ai/code/session_01F1U8LhkDL8Ln2hgsMyFu2G