feat: add CUDA ball-query backend for smooth lDDT loss - #261
heathcliff233 wants to merge 13 commits into
Conversation
Adds an opt-in CUDA ball-query implementation of the smooth lDDT loss
under openfold3/core/kernels/smooth_lddt/. The dense smooth_lddt_loss
remains the default and reference; ball-query becomes the better choice
once the dense [N, N] tensor dominates memory (~n_atom >= 2000).
Selected via config:
architecture.loss_module.diffusion:
smooth_lddt_backend: ball_query
smooth_lddt_top_k: <int>
chunk_size: null # ball-query rejects diffusion-loss chunking
The kernel is JIT-compiled on first use via torch.utils.cpp_extension.load
and ships the .cu/.cpp/.h sources as package data. ninja is required only
for this opt-in path and is exposed as a dedicated extra
("pip install -e '.[smooth-lddt-kernel]'") rather than a default dev/test
dependency.
Implementation notes:
- Forward kernel emits per-pair predicted squared distances directly,
eliminating the [B, N, K, 3] x_j gather entirely.
- Custom autograd Function saves only (pred, idx). Backward is a
dedicated CUDA kernel that scatters 2 * grad * (pred_i - pred_j)
via atomicAdd; no [B, N, K, 3] tensor is ever materialized.
- Scoring (sqrt -> abs -> sigmoid -> mask -> reduce) is wrapped in
torch.utils.checkpoint so all elementwise intermediates are freed
between forward and backward.
- Warp-cooperative ball query uses W=8 lanes per atom with reservoir
sampling for unbiased random neighbor selection when more than top_k
candidates qualify within the radius.
- bf16-mixed training keeps predictions in their native dtype end to
end; backward atomically accumulates into a fp32 grad buffer and
casts back to pred.dtype.
Adapted from PyTorch3D's ball-query and KNN backward
(https://github.com/facebookresearch/pytorch3d, BSD-3-Clause).
Per-file headers attribute the upstream portions and the OpenFold3
modifications. The OpenFold3 additions are released under Apache-2.0.
Tests in openfold3/tests/test_diffusion_loss.py cover forward parity
with the dense path, gradient parity (looser tolerance for atomicAdd
nondeterminism), the bf16 training path, and the subsampled-top_k
backward. A benchmark sweep lives in test_smooth_lddt_benchmark.py.
|
Big fan of this @heathcliff233 ! |
fix typo in draft smooth-lddt pixi recipe
jnwei
left a comment
There was a problem hiding this comment.
Thank you for this addition @heathcliff233 ! Overall this is a really well done PR, with extensive benchmarking and tests.
I have personally tested this on a DGX Spark and an A100, the new diffusion tests pass on both of these machines. I was unable to run the benchmark tests. It would be good if you provide a script to generate the mini pdb dataset.
The cuda kernel required by the ball query smooth lddt feature presents an interesting packaging challenge. I am calling on @sdvillal , our resident packaging expert for advice here.
In short, in order for us to continue to make openfold3 distributable on pypi, we cannot include any cuda / non python code in the pypi recipe. The custom cuda kernel that was present in the original OpenFold repository was the main blocker to why we cannot offer the original openfold on pypi.
My understanding (please correct me if I'm wrong @sdvillal ) is that we have two options for the ball query kernel:
-
Option 1: We make a separate package for the ball query kernel that is separately compiled. OpenFold3 can then install the ball query kernel as an extra dependency, similar to the approach used for installing cuequivariance. In this case, it would be good if @heathcliff233 can maintain the ball query package. This may involve creating packages that are suitable for multiple versions of cuda / pytorch.
-
Option 2: We keep the ball query source code in openfold3, but exclude it from the pypi recipe. A user who wishes to use the ball query source code will need to install from source.
For both Option 1 and Option 2, changes will need to be made to the base OpenFold3 code to try / except installation of the ball query code. I believe that @heathcliff233 already wrote most of the installation guards required, but we should explicitly test this with the different installation paths.
I think of these options, Option 1 is preferred. While Option 2 might be possible, it could create too much overhead for us to maintain in the long run. But I will defer to @sdvillal for his advice.
| "openfold3-editable", | ||
| "tests" | ||
| ] } | ||
| openfold3-cuda12-smooth-lddt = { no-default-feature = true, features = [ |
There was a problem hiding this comment.
I would not create two new environments, but simply add the extension to the existing ones (including the pypi envs)
|
Hi @heathcliff233 after chatting with @sdvillal offline, I think we can go forward with Option 2, e.g. include the smooth ball query code in OpenFold3, but do not include for OpenFold3 packaging to avoid having to distribute CUDA code. If that sounds good to you, we can move forward with the PR. Do you need any help with the script to fetch a minimal PDB set for the benchmarking tests? |
Add scripts/download_smooth_lddt_benchmark_data.py to fetch the minimal PDB dataset from S3 (or symlink from an existing training set). Add two larger samples (1tii: 5667 atoms, 1xfy: 7533 atoms) to the benchmark suite to demonstrate the ball-query scaling advantage at higher atom counts. Restructure the benchmark test to print a per-sample comparison table with time (fp32/bf16), peak memory, speedup, and memory savings across all backends.
|
Thanks @jnwei! Option 2 sounds good to me. I've pushed a commit that addresses the benchmark data request:
To run the correctness tests (numerical equivalence + gradient parity vs dense + bf16 + error handling): To run the benchmark (requires downloading the data first): |
jnwei
left a comment
There was a problem hiding this comment.
Thanks for including the script to download PDB examples @heathcliff233 ! Now I am able to run the benchmark tests. It's a very nice presentation of the speedups.
This is getting really close. I have a few more general change requests:
-
At this point, building OpenFold3 without compiling the smooth ball query kernel results in an import error. We will need to resolve these issues in order to ensure users who are using the pypi distribution can still run OpenFold3 withoug error. I will provide more detailed instructions for how you can test a build locally, and where you might introduce a fix.
-
Benchmark file locations: Could you pleas move both the test_smooth_lddt_benchmark.py and the download_smooth_lddt_benchmark_data.py to a sub directory under scripts (e.g.
scripts/benchmarks/ball-query-lddt-benchmark). At this point, we aren't well equipped to run these benchmark tests as part of our continuous integration / testing harness. -
Documentation: Could you add some documentation to the OpenFold3 docs about how to install the ball query kernel? I have reformatted the kernel documentation page slightly and added a stub for the ball query documentation.
- split is_ball_query_installed from is_ball_query_available so ball_query backend selection raises distinct errors when ninja/sources vs. CUDA runtime are missing, per jnwei's build-testing feedback - add libcu*-dev headers to smooth-lddt-kernel pixi feature so it is self-contained - move benchmark scripts under scripts/benchmarks/ball-query-lddt-benchmark and ignore local /data/ downloads - fill in the smooth lDDT ball-query section of docs/source/kernels.md (install requirements, pypi vs pixi coverage, top_k semantics, chunk_size incompatibility)
|
Thanks @jnwei , addressed in 03ddfa7
|
There was a problem hiding this comment.
Thanks for the documentation and the fixes!
I will aim to test these changes locally by early next week. Update I have now run the tests locally.
@heathcliff233 please merge main into this PR. I think are almost finished PR, and some of the tests will fail without the fixes from the main branch.
Once main has been merged in, all test seemsto pass with the pip install build using the [dev, deepspeed] extra options, following the deepspeed installation instructions
I have not tested the pip install openfold3[smooth-lddt-kernel] option, as I believe this option should be removed.
A few further documentation suggestions and I think we wrap things up. Thanks again for the hard work @heathcliff233 !
|
|
||
| The kernel is JIT-compiled with `ninja` on first use. Install with the extra: | ||
|
|
||
| ```bash |
There was a problem hiding this comment.
I think it is cleaner to tell users to install the ball query kernel via the just the pixi route. The extra CUDA toolchain requirements as noted in the documentation means that installation will not be tightly contained and will be a struggle for users to debug.
@sdvillal would you agree with this approach?
| loss: | ||
| diffusion: | ||
| smooth_lddt_backend: ball_query | ||
| smooth_lddt_top_k: 256 |
There was a problem hiding this comment.
This runner.yaml example should be expanded to include the full model_update sections, as you have in the example runner.yml file, e.g.
model_update:
custom:
architecture:
loss_module:
diffusion:
chunk_size: null
smooth_lddt_backend: ball_query
smooth_lddt_top_k: 128
Bring the branch up to date with upstream main (required for review). Regenerated pixi.lock after resolving the binary lock conflict.
Rename openfold3-cuda13-smooth-lddt back to openfold3-cuda13 and keep the smooth-lddt-kernel feature on the existing CUDA envs only. Remove the openfold3[smooth-lddt-kernel] optional dependency from pyproject.toml per review guidance to install via pixi rather than a pip extra.
Document ball-query install via openfold3-cuda12/openfold3-cuda13 pixi envs instead of a pip extra, and expand the runner YAML example to the full model_update.custom.architecture.loss_module.diffusion shape. Update install/error and skip messages to match.
|
Thanks @jnwei (and @sdvillal) — addressed the remaining review items in three commits:
Local check with the of3bq env: |
Add an independent Triton implementation under openfold3/core/kernels/triton/smooth_lddt_ball_query.py with dual protein/nucleotide radii (defaults 15/30 Å). Keep the CUDA path as default; select Triton via OPENFOLD3_SMOOTH_LDDT_IMPL=triton with only a thin dispatch in smooth_lddt/__init__.py so diffusion/config stay unchanged. Extend unit tests and the ball-query benchmark to cover the Triton backend. Keep install guidance on existing pixi CUDA env names (openfold3-cuda12/13 and *-pypi) per packaging review — no new env names or pip extra.
jnwei
left a comment
There was a problem hiding this comment.
Overall, the changes are great, I have tested building the sdist package locally with [dev, deepspeed] optional dependencies and can verify that everything passes.
I see that the last commit adds a triton version of the smooth_lddt_ball_query kernel. That is very exciting; we can consider including the triton versions in the default openfold3 package as it does not require additional non-python build artifacts.
Could I ask you to please make a new PR with the triton kernel? The code changes for the kernel are quite large and deserve a separate review. Additionally, we will want to reorganize the pixi environments to select the triton build when CUDA is not available.
Thank you very much for your hard work and patience. Once the triton commit is moved and the main branch is merged in, I think this PR is good to merge.
Thank you for the feedback. @Lim-ZQ and I are trying to migrate the original CUDA code to triton to get rid of the dependency headache. It took us sometime but we think the current triton version can now deliver similar optimizations and could serve as a replacement for the CUDA version (and potentially on AMD platform). We will revert this commit and create a new PR for the triton kernel recently. |
This reverts commit f4d6f2c.
|
Certainly, If you and @Lim-ZQ prefer, we can merge the triton PR#351 in lieu of this one. I see that you have already provided benchmark run statistics in the PR description there. If this is the case, could you please close this PR? We currently have a large number of PRs to process, but if possible, I will try to review #351 later this week. Thank you again for your contributions! |
|
Sure. Thank you very much for your help! |
Summary
Adds a CUDA ball-query backend for the smooth lDDT loss, opt-in via config and an
[smooth-lddt-kernel]install extra. The dense reference implementation insmooth_lddt_lossremains the default — nothing changes for users who don't enable the new path.For training on long targets, the existing dense smooth lDDT computes an
[N, N]pairwise-distance tensor for both predicted and ground-truth coordinates, then keeps[N, N]-shaped autograd intermediates through the elementwise scoring stage (sqrt → abs → 4× sigmoid → mask → reduce). ThisO(N²)term dominates both memory and runtime oncen_atomgrows past ~1500 atoms. The ball-query backend replaces the dense pairwise scan with a sparse[N, K]neighbor list and pushes both the predicted-distance computation and its gradient into CUDA, so memory grows withKrather thanN. With reservoir sampling on the kernel side, neighbor selection within the radius is uniform-random, matching the in-radius statistics that the dense backend would compute.Changes
openfold3/core/kernels/smooth_lddt/ball_query_ext/:BallQueryKernelCoopWithPred— warp-cooperative (W=8 lanes/atom) ball query with reservoir sampling, finds GT neighbors and emits predicted squared distances inline. Eliminates the[N, K, 3]x_jgather entirely.BallQueryPredBackwardKernel— dedicated backward that scatters2 · grad_dists · (pred_i − pred_j)intograd_predviaatomicAdd. Saves only(pred, idx)for backward — the[N, K, 3]diff tensor is never materialized. Pattern adapted from PyTorch3D's_ball_query+knn_points_backward(BSD-3-Clause; attribution preserved in headers).openfold3/core/kernels/smooth_lddt/:ball_query.py— JIT loader (torch.utils.cpp_extension.load) + thin wrappers around the CUDA entry points.__init__.py—_BallQueryWithPredDist(customautograd.Function) andball_query_smooth_lddt_loss. The remaining elementwise scoring is wrapped intorch.utils.checkpoint(use_reentrant=False)so all[N, K]intermediates are freed between forward and backward.openfold3/core/loss/diffusion.py— backend dispatch in the diffusion loss; new keyssmooth_lddt_backend(default"dense"),smooth_lddt_top_k. The existing internalchunk_size/run_low_mem_loss_fnpath is incompatible with ball-query and is rejected with a clear error.openfold3/projects/of3_all_atom/config/model_config.py— adds the two new keys with safe defaults; existing runner YAMLs are unchanged.pyproject.toml— new optional extra[smooth-lddt-kernel](justninja); not part of the default install. Adds abenchmarkpytest marker.examples/example_runner_yamls/smooth_lddt_ball_query.yml— example training override that enables the backend.openfold3/tests/test_diffusion_loss.pyadds dispatch + numerical-equivalence + gradient-equivalence + bf16 + chunk-size-error tests for the ball-query path.openfold3/tests/test_smooth_lddt_benchmark.pyis a--benchmark-onlysweep acrossn_atom,top_k, and dtype.Theoretical memory: dense vs ball-query
For batch B, atoms N, neighbors K, scoring dtype
sbytes (fp32 = 4 B; bf16/fp16 = 2 B):Dense backend (
smooth_lddt_loss) — keeps[B, N, N]-shaped tensors live for autograd. The dominant terms are:(x_i − x_j)²reduction inputs[B, N, N, 3]fp3212·B·N²dx,dx_gt − dx, 4× sigmoid,c,c·e(autograd saves)[B, N, N]× ~88s · B · N²[B, N, N]× few4·B·N²Total ≈
(16 + 8s)·B·N²bytes;O(B · N²)regardless of locality. The dense path cannot exploit ther ≤ 30 Åsmooth lDDT cutoff because the cutoff is applied after the full pairwise scan.Ball-query backend (
ball_query_smooth_lddt_loss) — keeps[B, N, K]-shaped tensors. The dominant terms are:idx(saved by autograd)[B, N, K]int648·B·N·Kdists_pred(saved by autograd)[B, N, K]ss·B·N·Kdists_gt(forward only)[B, N, K]fp324·B·N·Kpred(saved by autograd)[B, N, 3]s3s·B·NcheckpointTotal ≈
(8 + 2s)·B·N·Kbytes;O(B · N · K).Asymptotic ratio (dropping constants):
memory_dense / memory_bq ≈ N / K. At N=7680, K=256 that is ~30×; at N=4000, K=256 it is ~16×. The sameN/Kfactor governs runtime in the regime where the kernel is bandwidth-bound by the saved/scored tensors. The advantage disappears onceK ≥ N.Measured runtime + memory (RTX 4090, fwd+bwd peak)
Synthetic protein-density structures (~0.07 atoms / ų), B = 2 diffusion samples, fwd+bwd:
fp32
bf16
At the upper end of typical AF3 training samples (N≈7680), ball-query (K=256) is ~10× faster and uses ~22× less GPU memory than the dense baseline. The savings grow further for any N beyond that — the dense path scales as N², the ball-query path as N.
For the four small minimal-training samples in the unit-test suite (N = 448 / 635 / 1262 / 3329):
Crossover is around N≈600; ball-query starts winning materially around N≈1500. Reproduce with
pytest --benchmark-only openfold3/tests/test_smooth_lddt_benchmark.py(sweeps K over{128, 256, 512, 768, 1024, 2048}×{fp32, bf16}).Numerical equivalence
The relevant smooth-lDDT cutoff is 30 Å. For protein-density structures, the number of atoms within 30 Å of any given atom concentrates well below 1000. As long as
Kcovers the full in-radius set, the ball-query backend is bit-equivalent up to floating-point reorder to the dense path:test_smooth_lddt_ball_query_matches_dense:top_k = n_atom − 1→ forward agrees with dense atatol = rtol = 1e-5(fp32).test_smooth_lddt_ball_query_gradient_matches_dense: same setup → gradient w.r.t.xagrees atatol = 1e-3,rtol = 5e-3. The looser tolerance comes fromatomicAddreordering and the[N, K+1]reduction order vs.[N, N]in the dense path.test_smooth_lddt_ball_query_bf16_path: bf16 input → bf16 output; loss agrees with fp32 atatol = rtol = 5e-3, gradient atatol = rtol = 5e-2.test_smooth_lddt_ball_query_chunk_size_error: enabling bothchunk_sizeand ball-query is rejected.When
Kis smaller than the true in-radius count, ball-query returns a uniform-random size-Ksubsample of the qualifying neighbors per atom — an unbiased estimator of the same lDDT mean, with variance that vanishes asK → in_radius_count.Warp-cooperative search with reservoir sampling
Each query atom is owned by
W = 8cooperating threads. The candidate atoms are partitioned across the W lanes; each lane scans its stride and tests‖p1_i − p2_j‖² ≤ r². Qualifying neighbors race for output slots via a sharedatomicAddcounter, so the kernel never makes a global pass over allN²pairs and the work is balanced across the warp.Two-phase sampling:
seen < K): the firstKqualifying neighbors are written into output slots in order. Each slot is written by exactly one thread.seen ≥ K): each newly-qualifying neighbor is admitted with probabilityK / total_qualifiedby Vitter's Algorithm R. A per-lane hash-RNG (hash_rng(seed ⊕ n·1000003 ⊕ i·997 ⊕ lane·31)) replaces a uniformly random slot.To recover bit-exact agreement with dense, set
top_k ≥ max_neighbors_in_radius(in practicetop_k = n_atom − 1): both phases vanish, every qualifying neighbor lands in a fill-phase slot, and the output set is exactly the in-radius set. This is what the equivalence tests above use.The sampling itself is a pure function of
(seed, n, i, lane)— pass a fixedseed(e.g. the training step number) for reproducible survivors. Two remaining sources of nondeterminism:atomicAdd(counter)in the same cycle, the order of slot assignment depends on the GPU scheduler. Survivor set is identical, but its layout in the[K]axis can permute between runs. The loss is a sum overKso this does not affect the value, but if bit-exactidxis needed we can switch to a per-lane interleaved layout (each lane owns slotslane, lane+W, lane+2W, …).atomicAddreordering.BallQueryPredBackwardKernelaccumulates fp32 grads viaatomicAdd(matches PyTorch3D'sknn_points_backwardand PyTorch'sindex_add_). Withtorch.use_deterministic_algorithms(True)this kernel emits a "nondeterministic op" warning; a sort-and-segment-reduce variant is straightforward to add as an opt-in flag if needed (~2× backward time).Related Issues
None.
Testing
pytest openfold3/tests/test_diffusion_loss.py -q— 14/14 pass (5 new ball-query tests + dispatch error + dense baseline).pytest --benchmark-only openfold3/tests/test_smooth_lddt_benchmark.py— runs without regression; numbers above (RTX 4090, torch 2.7.1+cu126).ruff formatandruff checkclean on all touched files.Other Notes
smooth_lddt_backend: "dense") is unchanged; default install does not pullninja. Three explicit opt-ins are required: install extra (pip install -e ".[smooth-lddt-kernel]"), config (smooth_lddt_backend: ball_queryplussmooth_lddt_top_k), and CUDA availability.torch.utils.cpp_extension.load; setOPENFOLD3_SMOOTH_LDDT_VERBOSE=1for the full nvcc log.openfold3/core/kernels/smooth_lddt/README.mdfor the kernel layout and a more detailed view of the autograd boundary.