fix(pt): honor is_sorted in SE-Attention tabulation - #5905
Conversation
Forward the public sorting flag through the PyTorch forward and first-backward helpers so unsorted excluded-type rows are evaluated consistently with grad-grad. Fixes deepmodeling#5890 Coding-Agent: Codex Codex-Version: codex-cli 0.144.6 Model: gpt-5.6-sol Reasoning-Effort: xhigh
📝 WalkthroughWalkthroughPyTorch SE-Attention tabulation now propagates ChangesSE-Attention sorting propagation
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness bug in the PyTorch SE-Attention tabulation custom op where is_sorted was accepted/stored but not forwarded into the CPU/GPU backend entry points for forward and first-backward, causing unsorted neighbor rows to be treated as sorted. It ensures all autograd stages (forward, backward, double-backward) use the same sorting contract, matching expected backend behavior.
Changes:
- Thread
is_sortedthrough the PyTorch SE-Attention forward helper into both CPU and GPUtabulate_fusion_se_a_*entry points. - Thread
is_sortedthrough the first-backward helper so backward uses the same sorting contract as forward (and aligns with grad-grad’s existing behavior). - Add float32/float64 regression tests covering unsorted forward, backward, and second-order backward behavior.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
source/op/pt/tabulate_multi_device.cc |
Forwards is_sorted through SE-Attention forward and first-backward helper calls into CPU/GPU backend functions and preserves the flag across autograd stages. |
source/tests/pt/test_tabulate_fusion_se_atten.py |
Adds regression coverage ensuring unsorted neighbor rows affect forward/gradients correctly for both float32 and float64, including double-backward checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #5905 +/- ##
==========================================
+ Coverage 78.89% 78.92% +0.02%
==========================================
Files 1054 1068 +14
Lines 121774 123852 +2078
Branches 4408 4522 +114
==========================================
+ Hits 96076 97747 +1671
- Misses 24121 24488 +367
- Partials 1577 1617 +40 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
wanghan-iapcm
left a comment
There was a problem hiding this comment.
The plumbing is right. I walked all four autograd classes and every library entry point at head: is_sorted now reaches forward, first backward and grad-grad with the user's value in each; ctx->saved_data["is_sorted"] has exactly two stores and two matching reads, so no backward can read a key its forward never wrote; and since TORCH_LIBRARY_FRAGMENT registers a function pointer, the schema is inferred from the signature and cannot drift out of sync. The public signature is unchanged, so the TorchScript stub and the pt_expt fake kernel stay valid.
Worth stating explicitly what the pre-fix state was, because it is a bit worse than "a flag was ignored": the grad-grad helper already forwarded is_sorted correctly, while forward and first-backward did not. So the three autograd stages disagreed with each other -- the op was not consistently wrong, it was internally incoherent, which is why the second-order finite-difference check in the new test is such a sharp discriminator.
The hard-coded true on the non-attention path is correct rather than merely unchanged. is_sorted defaults to true in source/lib/include/tabulate.h, so behaviour is preserved exactly; and se_a applies exclusion per type-pair block with a separate op call per embedding index, so an excluded block is uniformly zero within one invocation and never interleaves. TF was already correct (it declares is_sorted as an op attr and reads it in every kernel), Paddle has no tabulate op, and pt_expt calls this same op so it is fixed transitively.
On why no existing test caught this. This is the part I found genuinely interesting, because there is already an end-to-end test that trains, freezes and compresses a se_atten model with exclude_types and compares against the uncompressed model at 1e-10 -- TestDeepPotATPBCExcludeTypes in source/tests/pt/test_model_compression_se_atten.py. It passes pre-fix, and not because of tolerance or because the op is bypassed. It passes because in that model the fold is never armed.
forward builds the env-mat from the unmasked nlist and applies the exclusion mask afterwards, so an excluded row is exactly all-zero including its em_x. Padding rows are not zero: prod_env_mat normalises without re-masking, so a padding row becomes (-avg_r/std_r, 0, 0, 0). The kernel's guard needs ago == xx, where ago is the last neighbour's scalar -- and se_atten_v2 defaults set_davg_zero=False, so that sentinel is non-zero while excluded rows are zero. The guard never matches an excluded row and fires only on the genuine trailing padding block, where the (nnei - jj) fold is exact. The nlist ordering is actually maximally adversarial there (the excluded O-H pairs are the nearest neighbours, sitting ahead of real ones), and it still cannot trigger.
So the untested cell is compressed se_atten, non-empty exclude_types, and an env-mat where the excluded row's em_x equals the padding sentinel -- which is what set_davg_zero=True gives you, and that is the default for plain se_atten rather than se_atten_v2. Nothing in the repo crosses it.
Your op-level test does construct exactly that configuration and is a real regression test: hand-derived closed-form expectations rather than the op compared against itself, all three autograd stages, both dtypes, and it runs in CI since the custom ops are built there. Pre-fix the forward returns 0.6*c where you assert 1.4*c, and dy_dem comes out [108, 0, 0] against your [36, 108, 27] -- not a tolerance question.
What would round it out is one end-to-end case crossing the cell above: a compressed se_atten (not v2) with exclude_types and set_davg_zero: True, asserted against the uncompressed model the way TestDeepPotATPBCExcludeTypes already does. That is the configuration a user would actually hit, and it is the one the current suite structurally cannot reach.
The one thing I would like your view on before approving. This PR makes the is_sorted=True fold genuinely live for se_atten for the first time, and the Python-side guarantee that keeps it safe was never added for that descriptor family:
deepmd-kit/deepmd/pt/model/descriptor/se_atten.py
Lines 813 to 817 in dba50c5
Compare the se_e2_a version, where the method returns self.compress specifically so that the sorted-nlist precondition holds, with a comment explaining the coupling:
The exposure is a compressed DPA1 with empty exclude_types, which gives is_sorted = True, so the fold applies -- but need_sorted_nlist_for_lower() returning False means the forward_lower neighbour list from C++/LAMMPS, built at rcut+skin and not pre-sorted, can place an out-of-rcut neighbour with sw == 0 ahead of real ones. The guard then fires early and multiplies that row by (nnei - jj), silently producing a wrong descriptor. That is the same failure mode fixed for se_e2_a. se_t.py and se_t_tebd.py have the identical unconditional return False.
This hazard predates your PR -- before it the flag was ignored and the kernel behaved as sorted regardless, so the LAMMPS exposure was already there. I am not claiming you introduced it. But it is the other half of the same invariant, and this PR is what makes the flag meaningful, so I would rather we decide deliberately whether it belongs here or in an immediate follow-up than leave it implicit. return self.geo_compress would mirror the se_a fix.
Two smaller notes inline. Also worth a line in the description: with non-empty exclude_types the fold is now genuinely disabled, so compressed DPA1 pays a full nnei loop where it previously short-circuited. Correct, but a measurable cost that users may notice.
Make geometrically compressed DPA1 request the sorted lower-neighbor-list path, and add operator, forward_lower, and end-to-end regression coverage for the sorting invariant. Coding-Agent: Codex Codex-Version: codex-cli 0.144.6 Model: gpt-5.6-sol Reasoning-Effort: xhigh
|
Addressed in 8a3a023:
I also audited History-wise, there are two distinct origins:
Validated with the rebuilt PyTorch C++ op, all 12 SE-Attention tabulation tests, the new Coding agent: Codex |
wanghan-iapcm
left a comment
There was a problem hiding this comment.
All five points from the previous round check out against 8a3a02381.
DescrptBlockSeAtten.need_sorted_nlist_for_lower() now returns self.geo_compress, which is the right predicate -- narrower than se_a's self.compress, since only the geometric path reaches the se_a tabulate op, and geo_compress is initialized in __init__ so uncompressed models are unaffected. The new forward_lower regression is a genuine one: it fails pre-fix on the need_sorted_nlist_for_lower() assertion alone, and lines 114-115 verify the constructed list really does contain both in-cutoff and out-of-cutoff real neighbors rather than assuming it.
On the operator fixture, I re-derived the expectations after the a1 change: every one of the nine previously-verified numbers scales by exactly 1.5, and the em_x assertion is now discriminating instead of vacuous. The reconfigured end-to-end case crosses the cell I described -- with set_davg_zero: true the padding rows normalize to all-zero and so match excluded rows exactly, which is what arms the guard; pre-fix that case folds an excluded row with real neighbors behind it and misses the 1e-10 comparison.
One correction on my side: my note about se_t.py and se_t_tebd.py was over-broad and your rebuttal is right. The ago == xx && ... && is_sorted fold and its break exist only in the three se_a kernels, on CPU (tabulate.cc 186/275/379) and GPU (tabulate.cu 329/414/518); se_t, se_t_tebd and se_r take no is_sorted parameter and have no fold, so an override there would have no effect. Nothing to change.
Thanks also for the performance note in the description.
Summary
is_sortedthrough the PyTorch SE-Attention forward helper to the CPU and GPU library entry pointsforward_lowercoverage for unsorted rcut+skin lists and an end-to-end plainse_attencase withexclude_typesandset_davg_zero: truese_apath intentionally keeps the sorted fold enabledFixes #5890
Historical origin
tabulate_fusion_se_attenwhile omitting the backendis_sortedargument in forward and first backward.se_attengeometric compression whileneed_sorted_nlist_for_lower()still returnedFalse. fix(pt): fix lammps nlist sort with large sel #3993 introduced that API/default earlier, but compression did not yet use the sorted fold.Performance note
With non-empty
exclude_types, the correctedis_sorted=Falsepath traverses the full neighbor list instead of applying the sorted-padding fold. This may be measurably slower, but avoids silently folding interleaved excluded rows.Testing
deepmd_op_ptC++ target buildpython -m pytest source/tests/pt/test_tabulate_fusion_se_atten.py -v(12 passed)python -m pytest source/tests/pt/model/test_compressed_se_atten_forward_lower.py -v(1 passed)python -m pytest source/tests/pt/test_model_compression_se_atten.py::TestDeepPotATPBCExcludeTypes::test_1frame -v(1 passed)ruff format .ruff checkon all modified Python filesdp --versiondp --pt -hdeepmdanddeepmd.ptruff check .was also run; it reports five pre-existing findings indeepmd/jax/jax_md/__init__.pyanddeepmd/tf/entrypoints/__init__.py, outside this PR.Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh