Skip to content

perf(dpa4): batch the SO3/grid contractions over (D,F); keep use_amp through pt_expt assembly - #5960

Open
wanghan-iapcm wants to merge 18 commits into
deepmodeling:masterfrom
wanghan-iapcm:perf-dpa4-grid-contract
Open

perf(dpa4): batch the SO3/grid contractions over (D,F); keep use_amp through pt_expt assembly#5960
wanghan-iapcm wants to merge 18 commits into
deepmodeling:masterfrom
wanghan-iapcm:perf-dpa4-grid-contract

Conversation

@wanghan-iapcm

@wanghan-iapcm wanghan-iapcm commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Users reported that compiled DPA4 training runs ~2x slower on pt_expt than on pt. This PR is the result of chasing that: a performance bug in how the SO3 contractions were lowered, and a correctness bug where a configured use_amp: false was lost while pt_expt assembled the model.

Changes

1. Weight broadcast across the node axis (so3.py, lora.py, grid_net.py)

matmul(x[..., None, :], weight[None, ...]) makes the node count N the matmul BATCH, so matmul broadcasts the weight to (N, D, F, Cin, Cout) and autograd then reduces that whole expanded gradient (ExpandBackward0) back to the parameter shape. At the water example's sizes a 165 K-element weight expanded to 191 M elements (~0.8 GB) per call, and the reduce was the single costliest kernel of a training step (45.6 ms, 3x per step).

The fix batches the contraction over the small (D, F) axes so N stays the GEMM ROW dimension and the weight is used in place. Micro-benchmark, fwd+bwd at the real shapes: 16.48 ms -> 1.09 ms (15x).

Two lookalike sites in projection.py are deliberately NOT changed: their operands are requires_grad=False buffers, so no backward reduce exists. Verified rather than assumed.

2. The same lowering for the frame mixers (_degree_batched_matmul)

Review found the FrameContract / FrameExpand mixers still on the broadcast spelling. They now share one helper, _degree_batched_matmul, written identically on the dpmodel side (dpmodel/descriptor/dpa4_nn/grid_net.py) and the pt side (pt/model/descriptor/sezm_nn/grid_net.py). Because it does no reshape, an empty node axis (N == 0) flows through unchanged instead of hitting a reshape error — pinned by a test.

3. use_amp was lost during pt_expt model assembly (correctness)

use_amp is a training-runtime policy, not model state, so it stays OUT of the portable serialization record (this is the #5963 position, and the jax deserializer actively rejects records carrying use_amp: true). The bug was elsewhere: pt_expt assembled its model by converting an already-populated dpmodel instance, and that conversion round-trips the component through deserialize(serialize()). Anything that is deliberately not in the portable record — use_amp among it — was therefore dropped, and training silently ran under bfloat16 autocast even when the input configured use_amp: false.

The fix is at the assembly boundary, not in the record: pt_expt now constructs the wrapped class directly (auto_wrapped_class(...) in make_model.py, get_model.py, and the bridging composition path), so a live constructor-supplied component keeps its runtime state. The rule is stated once, in the auto_wrapped_class docstring; the call sites reference it.

An earlier revision of this PR instead added use_amp to serialize(). That was reverted in review — it put a runtime knob into the portable record and would have broken the jax contract.

Also removed in review: an enable_tf32 / DP_TF32_INFER implementation for pt_expt. It contributes nothing to the speedup measured below (the benchmark card has no TF32 silicon), and #5958 owns the pt_expt training-runtime alignment — including the documented position that pt_expt always runs at "highest" matmul precision. pt_expt therefore keeps master's warn-and-ignore behavior for enable_tf32.

Benchmark

DPA4 water example (examples/water/dpa4), one Tesla T4, torch 2.11, fp32 (use_amp: false), batch size 6. Steady-state seconds per training step, obtained by differencing the wall time of a 33-step and a 3-step run of the same config, which cancels every one-time cost (import, data load, statistics, torch.compile / make_fx lowering). All five arms were measured in one session on the same machine; run-to-run variation is about 2-3%.

Provenance: measured at ae720432b, the head at which this PR was opened — i.e. BEFORE the review changes (change 2, the frame-mixer lowering, and change 3's move from serialize() to the assembly boundary). Change 1, which is where the entire speedup comes from, is unmodified since. The numbers have not been re-measured on the current head; a re-run is pending and I will post it rather than silently reuse these.

training mode pt (reference) pt_expt at master pt_expt at ae720432b speedup vs master
eager 0.891 s/step 1.555 s/step 0.921 s/step 1.69x
compiled 0.545 s/step 1.611 s/step 0.535 s/step 3.01x

This reproduces the reported issue at master — pt_expt compiled was 3.0x slower than pt compiled, and even slower than its own eager path, because the broadcast-weight contraction lowers to worse code under inductor than under eager cuBLAS. After the fix pt_expt is at parity with pt: eager within 3.4%, compiled within measurement noise.

Known limitations

  • Backward numerics are covered for the frame mixers, not for the SO3 / LoRA contractions. test_dpa4_frame_mixers.py compares _degree_batched_matmul's weight gradient against the pt module's at rtol/atol 1e-12. The rewritten SO3 and LoRA contractions are pinned on the forward against an explicit einsum reference (rtol/atol 1e-12, numpy and torch namespaces); their backward is still exercised only by tracing, not compared by value.
  • The pt / pt_expt TF32 policy gap remains open. On Ampere+ cards pt runs training matmuls under TF32 (enable_tf32, default True) while pt_expt ignores the key with a warning; the two backends are not speed-comparable there. Deferred to the feat(pt_expt): align the training runtime with pt #5958 training-runtime series.
  • The residual compiled gap vs pt is not stable across sessions. An earlier session measured pt_expt compiled 10.8% slower than pt compiled; the benchmark above measured it 1.7% faster. Both are within a couple of run-to-run standard deviations, so I treat compiled as at parity and the earlier gap as unconfirmed.
  • The history contains churn at the GridBranch router (7518a417c -> 01c58e665 -> 75459610a -> 504bb2430 -> 157444204): a matmul spelling introduced, reverted, reintroduced, and finally restored to master's line. The site is byte-identical to master in the final diff. The degenerate GEMM that profiling found there existed only on this branch, so it is not a fix — I have left the commits rather than rewriting pushed history, and would squash them on request.
  • Unrelated but found while benchmarking: torch >= 2.11 ships no Volta (CC 7.0) kernels, and compiled training requires >= 2.11 via check_compile_torch_version. Compiled DPA4 training is therefore impossible on V100 with official wheels; T4 (CC 7.5) is the oldest card that works.

Tests

  • source/tests/common/dpmodel/test_dpa4_frame_mixers.py_degree_batched_matmul vs the pt module: forward parity, the N == 0 contract, and weight-gradient parity.
  • source/tests/common/dpmodel/test_dpa4_lora.py — new test_lora_so3_call_matches_einsum_contract: LoRASO3.call against the explicit einsum("ndfi,difo->ndfo") reference with a nonzero adapter, on both the numpy and torch namespaces, for n_focus 1 and 2.
  • source/tests/pt_expt/model/test_get_model_dpa4.pyuse_amp survives model assembly (both branches), for the plain and the bridged/composed construction paths.
  • source/tests/common/dpmodel/test_descrpt_dpa4.pyuse_amp is absent from the portable serialization record and defaults on deserialize.
  • Existing test_grid_branch[1]/[2] cover the changed SO3 contraction against the pt implementation at rtol 1e-12.
  • Run locally: 434 passed / 10 skipped across the dpa4 dpmodel, pt_expt and cross-backend parity suites, plus the pt_expt model suite. CUDA-gated precision-context cases were run on a T4 (29/29).

Test status caveat — resolved

An earlier revision of this description flagged two locally failing pt_expt AOTI-freeze tests (test_zbl_bridging.py::test_native_spin_with_bridging_graph_freeze_and_deep_eval, test_dpa4_zbl_parallel.py::TestBridgedSpinGraphSelfComm::test_freeze_embeds_with_comm_artifact) as unadjudicated. They are now adjudicated as pre-existing and environmental, not caused by this branch: a clean upstream/master worktree on the same machine fails both with the identical InductorError: assert isinstance(index, CppCSEVariable) and index.is_vec (torch 2.11 CPU-SIMD codegen bug on an atomic_add scatter buffer), and both tests pass on this branch with the known workaround torch._inductor.config.cpp.simdlen = 1 (2 passed). The same bug is already documented in source/tests/infer/gen_dpa4.py / gen_dpa2.py.

Han Wang added 9 commits August 5, 2026 01:37
The GridBranch router contraction einsum("ngfhc,nfh->ngfc") was written as
a broadcast multiply followed by a reduce over the branch axis.  That
materialises the entire (N, G, F, H, C) product -- roughly 0.8 GB at the
grid resolution of examples/water/dpa4 -- writes it to memory and reads it
straight back, and the backward pays the same traffic again.

An op-level CUDA profile of a DPA4 training step measured this single
reduce at 45.6 ms per call over a [1152, 9, 1, 32, 576] operand, three
calls per step: the most expensive kernel in the run.  The pt backend
spells the same contraction as torch.einsum and never builds the
intermediate.

Use xp.matmul instead, which is array-API standard (unlike np.einsum,
which is what the broadcast form was avoiding) and contracts H in place so
only the (N, G, F, C) result is written.  matmul broadcasts its leading
batch axes, so the router reshapes to (N, 1, F, 1, H) and lines up with
value's (N, G, F, H, C) without any permute -- a permute would reintroduce
the copy this removes.
This reverts commit 7518a41.

Measurement did not support it.  An op-level profile attributed the 45.6 ms
reduce to ExpandBackward0, not to this multiply, and re-benchmarking after
the change moved DPA4 eager training by nothing (1.514 -> 1.552 s/step, i.e.
run-to-run noise) while the offending kernel stayed byte-identical at
410.7 ms.  The GridBranch product is well under the size that would matter.

Since matmul is autocast-listed where mul/sum are not, keeping it would have
silently moved this contraction into bf16 under the autocast region for no
measured gain.  The actual site is the broadcast weight in so3.py, fixed
separately.
Both so3 channel mixers spelled their einsum as a batched matmul with the
NODE/EDGE axis as the matmul BATCH and the weight carrying a dummy leading
axis:

    matmul(x[:, :, :, None, :], weight_expanded[None, ...])

matmul broadcasts batch axes, so this expands the weight to
(N, D, F, Cin, Cout).  For examples/water/dpa4 that turns a 165K-element
parameter into 191M elements -- about 0.8 GB -- on every call, and autograd
must then reduce the whole expanded gradient back to the parameter shape.

An op-level CUDA profile of a DPA4 training step attributed 45.6 ms per
call to that ExpandBackward0 reduce over a [1152, 9, 1, 32, 576] operand,
three calls per step, making it the most expensive kernel in the run; the
ChannelLinear twin cost a further ~7-9 ms per call over [102510, 1, 32, 64].
The pt backend spells the same contraction as torch.einsum and never
expands the weight.

Batch over the small (D, F) / (F,) axes instead, which keeps N as matmul
ROWS.  The weight is then used in place and its gradient is an ordinary
matmul.  The transposes this adds touch only the (N, D, F, C) operands,
which are orders of magnitude smaller than the expanded weight.
Follow-up to the so3.py fix, applying the same correction wherever a
contraction was spelled so that the NODE axis becomes the matmul BATCH and
a trainable tensor is broadcast across it:

* grid_net.GridBranch  einsum "ngfhc,nfh->ngfc" -- was a broadcast multiply
  plus a reduce, materialising an (N, G, F, H, C) product H times the size
  of its own result.
* grid_net.FrameContract / FrameExpand  einsum "ndfi,dio->ndfo" -- broadcast
  the per-degree weight to (N, D, i, o).  Both now share
  _degree_batched_matmul, which batches over the small degree axis.
* lora.call  einsum "ndfi,difo->ndfo" -- the LoRA twin of the so3.py site.

In every case autograd had to reduce the fully expanded gradient back to
the parameter shape on each step; batching over the small (D, F) axes keeps
N as matmul ROWS so the weight is used in place.

The two projection.py sites that share the [None, ...] spelling are left
alone deliberately: to_grid_mat / from_grid_mat are registered as BUFFERS
with requires_grad=False (verified on a constructed DPA4), so no gradient
is taken for them and none of the expensive half applies.

Covered by the existing pt-parity gates, which construct these classes
directly: test_dpa4_frame_mixers.py (FrameContract/FrameExpand, fp64
weight-copied vs pt), test_dpa4_gridbranch_frames.py, test_dpa4_lora.py,
and test_dpa4_dpmodel_parity.py.
…nored

The descriptor's use_amp flag was never written to serialize(), and
deserialize() feeds config straight into __init__, so any rebuild fell back
to the True default.  The pt_expt backend rebuilds the descriptor from that
dict, so 'use_amp: false' in the input was silently discarded and training
stayed in bfloat16 autocast; only the pt backend, which builds once from
the config, honoured it.

Caught while benchmarking: disabling AMP made pt 23% faster on a Turing GPU
(no bf16 tensor cores) while pt-expt did not move at all, and an op-level
profile showed pt-expt still spending 45% of its device time in bf16 gemm
kernels with use_amp=false.

Add the key to both the dpmodel and pt serialize configs so the two stay
key-identical and the flag survives a cross-backend round-trip.  Records
written before this change deserialize unchanged -- the key is simply
absent and __init__ supplies the default.

The pre-existing round-trip tests compare forward OUTPUTS, which cannot
catch this: dpmodel never autocasts, so the outputs agree whatever use_amp
says.  The new test pins the attribute itself, for both boolean values, and
fails on the previous code.
pt_expt accepted `model.enable_tf32` and threw it away with a warning, so
DPA4/SeZM training always ran at "highest" matmul precision while the pt
backend -- reading the same input.json -- ran its training forwards under
`set_float32_matmul_precision("high")`.  On Ampere and later that is the
difference between TF32 tensor cores and fp32 CUDA cores for every matmul,
and GEMM is ~60% of compiled device time on this workload, so the two
backends were not comparable on that hardware at all.

Mirror pt's policy exactly: TRAINING forwards follow `enable_tf32`
(argcheck default True), EVAL forwards follow `DP_TF32_INFER` (0/1/2 ->
highest/high/medium, invalid values rejected).  Scope matches pt, where
argcheck declares the knob inside the dpa4 model arg block and only the
sezm builders wire it: pt_expt attaches it in `get_sezm_model` and
`get_native_spin_model`, and every other model keeps class defaults that
select full fp32 in both modes.

Ownership: `call_common` is the single owner for eager forwards -- every
pt_expt model's `forward` reaches the backbone through it, and the export
trace roots at `call_common_lower`, so the precision switch never enters an
exported graph.  The compiled path needs its own application because
`_CompiledModel.forward` bypasses `call_common` entirely; placing the
context only on the model would have left it dead on exactly the path this
is meant to speed up.  The context spans the lazy compile there, since
Inductor picks its GEMM backend while lowering.

Gating on `self.training` is what keeps the existing 1e-12 parity tests
valid: eval and export stay at "highest" unless DP_TF32_INFER asks
otherwise.
The GridBranch router contracts the branch axis H, and H is a handful (1 in
the water example).  Spelling it as `matmul(router.reshape(N, 1, F, 1, H),
value)` therefore asks cuBLAS for a batched GEMM with M=1 and K=H, which it
serves from its small-N kernels (`gemmSN_*`, `gemmk1`).

A shape-resolved profile of a compiled DPA4 training step found this to be
the single largest GEMM in the run:

    aten::bmm [[119808, 1, 1], [119808, 1, 96]]   0.0249 s/step forward

with its two backward siblings adding 0.0138 s/step -- together ~0.039 s/step
against a total pt-vs-pt_expt compiled gap of 0.055 s/step.  The batch is
N(1152) * G(104) and K is 1: no contraction is happening at all, it is a
scalar multiply routed through a GEMM kernel.

Micro-benchmarked fwd+bwd at those exact shapes:

    H=1:  matmul 7.523 ms   mul+sum 1.828 ms   (4.1x)
    H=3:  matmul 4.436 ms   mul+sum 4.453 ms   (equal)

so the broadcast form is never worse.  The comment this replaces claimed the
intermediate costs "H times the size of the result" -- true, but H is small,
and the measurement shows it does not pay for the degenerate GEMM.

This restores the spelling that 7518a41 replaced and 01c58e6 restored
once already; that revert was justified on a different workload (AMP-on
eager, where the site was invisible) and 7545961 then re-applied the
matmul as part of a broader sweep without re-measuring this site.  The
numbers above are what was missing both times.
Conflict in deepmd/pt_expt/model/get_model.py, resolved keeping both sides:

* imports -- this branch added `os` (for DP_TF32_INFER), upstream added
  `TYPE_CHECKING`; kept both.
* the bridging return -- upstream (deepmodeling#5939) factored the ZBL composition into
  `_compose_bridging`, while this branch applied `_apply_tf32_policy` at each
  return site.  Took upstream's helper and attached the TF32 policy to
  whichever model it returns, so both changes keep their behavior.
The contraction and TF32 comments had grown into measurement essays. Keep
the part a reader needs -- why the obvious spelling is wrong -- and drop the
profiling detail, which belongs in the PR discussion rather than the source.

Also drops a `logging` import left unused when the enable_tf32 warn-once test
was replaced.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR replaces selected broadcasted matrix multiplications with dimension-aware batched operations. It adds cached PyTorch wrapper creation, routes model assembly through wrapped atomic models, and adds regression tests for empty batches, gradients, and DPA4 use_amp behavior.

Changes

DPA4 batched descriptor contractions

Layer / File(s) Summary
Batched descriptor contractions
deepmd/dpmodel/descriptor/dpa4_nn/grid_net.py, deepmd/dpmodel/descriptor/dpa4_nn/lora.py, deepmd/dpmodel/descriptor/dpa4_nn/so3.py, deepmd/pt/model/descriptor/sezm_nn/grid_net.py
Frame mixers, LoRA SO(3), and SO(3) linear operations use batched matrix multiplication without broadcasting weights across the batch dimension.
Empty-batch and gradient validation
source/tests/common/dpmodel/test_dpa4_frame_mixers.py
NumPy and Torch tests verify empty-batch output shapes and forward, input-gradient, and weight-gradient parity with einsum references and the alternate lowering.

PyTorch model wrapping and AMP preservation

Layer / File(s) Summary
Cached wrapper model assembly
deepmd/pt_expt/common.py, deepmd/pt_expt/model/make_model.py, deepmd/pt_expt/model/get_model.py
auto_wrapped_class caches wrapper subclasses. Model factories use wrapped atomic models. Bridging composition constructs LinearEnergyModel with live wrapped children.
AMP behavior validation
source/tests/common/dpmodel/test_descrpt_dpa4.py, source/tests/pt_expt/model/test_get_model_dpa4.py
Tests verify disabled use_amp values at construction and across DPA4, standard, ZBL, and linear_ener assembly paths. Portable serialization omits the disabled value, and the default remains enabled.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Merge Risk: ⚪ Minimal · up to e2f54

The PR improves DPA4 performance and preserves configuration serialization behavior; no actionable merge-blocking risk remains. Additional empty-batch coverage is a minor follow-up.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main performance and use_amp assembly changes in the pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
source/tests/pt_expt/model/test_get_model_dpa4.py (1)

323-352: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test non-default evaluation precision in the context.

This test runs evaluation only with DP_TF32_INFER unset. Lines 298-313 verify the stored attribute, but they do not verify that tf32_precision_ctx() uses "high" or "medium".

Parameterize this test with DP_TF32_INFER="1" and "2". This prevents an evaluation branch that always selects "highest" from passing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@source/tests/pt_expt/model/test_get_model_dpa4.py` around lines 323 - 352,
Extend test_tf32_precision_ctx_selects_and_restores to parameterize
DP_TF32_INFER for evaluation cases, covering "1" and "2" with expected
precisions "high" and "medium" respectively, while keeping training cases unset.
Set the environment variable per case before entering tf32_precision_ctx so the
evaluation branch is verified and precision restoration remains asserted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@deepmd/pt_expt/model/make_model.py`:
- Around line 485-507: The shared process-wide precision mutation in
tf32_precision_ctx must not overlap across concurrent forwards. Add and reuse a
model-level lock to serialize the entire precision-setting, yield, and
restoration block, or explicitly document concurrent forwards as unsupported if
that is the intended contract.

---

Nitpick comments:
In `@source/tests/pt_expt/model/test_get_model_dpa4.py`:
- Around line 323-352: Extend test_tf32_precision_ctx_selects_and_restores to
parameterize DP_TF32_INFER for evaluation cases, covering "1" and "2" with
expected precisions "high" and "medium" respectively, while keeping training
cases unset. Set the environment variable per case before entering
tf32_precision_ctx so the evaluation branch is verified and precision
restoration remains asserted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4121890c-a131-4838-8ece-1beed8953924

📥 Commits

Reviewing files that changed from the base of the PR and between a3195b0 and ae72043.

📒 Files selected for processing (10)
  • deepmd/dpmodel/descriptor/dpa4.py
  • deepmd/dpmodel/descriptor/dpa4_nn/grid_net.py
  • deepmd/dpmodel/descriptor/dpa4_nn/lora.py
  • deepmd/dpmodel/descriptor/dpa4_nn/so3.py
  • deepmd/pt/model/descriptor/sezm.py
  • deepmd/pt_expt/model/get_model.py
  • deepmd/pt_expt/model/make_model.py
  • deepmd/pt_expt/train/training.py
  • source/tests/common/dpmodel/test_descrpt_dpa4.py
  • source/tests/pt_expt/model/test_get_model_dpa4.py

Comment thread deepmd/pt_expt/model/make_model.py Outdated
Han Wang added 2 commits August 6, 2026 15:24
The router site ends up byte-identical to master: 7518a41 replaced the
broadcast sum with a matmul, 504bb24 put the sum back, and the net diff was
a one-line comment swapped for five -- losing master's (N, G, F, C) shape
annotation on the way.  Restore master's line exactly, so the branch touches
this site not at all.

The degenerate GEMM that profiling found there was self-inflicted: it existed
only on this branch, never on master, so "fixing" it delivered nothing.

Also corrects the so3 ChannelLinear comment, which claimed the contraction is
batched over the focus axis.  What matters is that B stays the GEMM rows; at
n_focus=1 -- every shipped config -- both permutes are contiguous views and
the whole thing is one (B, Cin) x (Cin, Cout) GEMM at no copy cost.
…backend"

This reverts the pt_expt TF32 policy (99d33ea plus its comment edits in
ae72043), restoring the warn-and-ignore behavior on master.

The knob is unrelated to this PR's measured speedup (the benchmark card has
no TF32 silicon; the whole 1.69x/3.01x gain comes from the contraction fix),
its benefit was never measured, and PR deepmodeling#5958 owns the pt_expt training
runtime alignment -- including the documented position that pt_expt runs at
'highest' matmul precision.  Keeping a second, contradicting implementation
here would split ownership of the same policy across two PRs.
@wanghan-iapcm wanghan-iapcm changed the title perf(dpa4): remove broadcast/degenerate GEMM spellings; honor enable_tf32 in pt_expt perf(dpa4): remove broadcast/degenerate GEMM contractions; serialize use_amp Aug 6, 2026
@wanghan-iapcm wanghan-iapcm changed the title perf(dpa4): remove broadcast/degenerate GEMM contractions; serialize use_amp perf(dpa4): stop broadcasting weights across the node axis; fix use_amp serialization Aug 6, 2026
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.51%. Comparing base (62cd093) to head (81e756d).
⚠️ Report is 4 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #5960      +/-   ##
==========================================
- Coverage   79.64%   79.51%   -0.13%     
==========================================
  Files        1085     1085              
  Lines      126583   127163     +580     
  Branches     4592     4598       +6     
==========================================
+ Hits       100811   101114     +303     
- Misses      24120    24397     +277     
  Partials     1652     1652              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@iProzd iProzd added the P0 Blocks the DPA4/DPA4C release. label Aug 10, 2026
@njzjz njzjz added this to the v3.2.0 milestone Aug 10, 2026

@njzjz-bot njzjz-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The non-empty contractions preserve the old formulas and gradients, and the use_amp serialization change is backward compatible. One introduced edge-case regression remains: the shared frame-mixer helper cannot reshape an empty leading node/edge axis, although the previous matmul returned a correctly shaped empty result. The inline suggestion fixes both FrameContract and FrameExpand without restoring weight broadcasting.

Codex quota is about to reset, so I am using the remaining token budget to complete a concentrated review pass over the outstanding PRs.

Coding agent: Codex
Codex version: codex-cli 0.144.6
Model: gpt-5.6-sol
Reasoning effort: xhigh

"""
n_batch, coeff_dim, n_focus, _ = coeff.shape
coeff_d = xp.reshape(
xp.permute_dims(coeff, (1, 0, 2, 3)), (coeff_dim, n_batch * n_focus, -1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve empty node/edge batches in the new contraction

When N == 0, this reshape becomes (D, 0, -1). NumPy and PyTorch cannot infer -1 from a zero-element array, so both FrameContract and FrameExpand now raise instead of returning an empty (0, D, F, o) result as the previous broadcasted matmul did. This is reachable when the cross-grid leading axis is an empty graph/edge set or a distributed rank owns no nodes. I reproduced it for both mixers on this head; JAX also fails. Please use explicit channel widths in both reshapes and add an N=0 regression test.

Suggested change
xp.permute_dims(coeff, (1, 0, 2, 3)), (coeff_dim, n_batch * n_focus, -1)
input_dim = weight.shape[-2]
output_dim = weight.shape[-1]
coeff_d = xp.reshape(
xp.permute_dims(coeff, (1, 0, 2, 3)),
(coeff_dim, n_batch * n_focus, input_dim),
)
out = xp.matmul(coeff_d, weight)
out = xp.reshape(out, (coeff_dim, n_batch, n_focus, output_dim))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 63071be exactly as suggested: both reshapes in _degree_batched_matmul (the one helper behind FrameContract and FrameExpand) now use explicit channel widths taken from the weight shape, so an N == 0 batch flows through as an empty (0, D, F, o) result like the previous broadcasted matmul. Regression test test_empty_batch_passes_through covers both mixers on the numpy and torch namespaces (verified to fail on the -1 version). The jax namespace shares this exact code path but was not run locally.

@OutisLi

OutisLi commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Please merge or rebase the latest upstream/master before continuing this review. PR #5963 has already been merged into master and fixes a correctness issue relevant to the contractions changed here: FrameContract.weight and FrameExpand.weight are added to the pt_expt trainable-promotion table. At the current head (1e56cf6d), the default examples/water/dpa4/input.json constructs four such modules, but all four weights are still non-trainable buffers, whereas the pt backend registers them as trainable nn.Parameters. This also means the current-head assumption that these frame weights incur a trainable-weight ExpandBackward0 reduction does not hold for pt_expt. Syncing current master should bring in the #5963 fix naturally; please re-run the relevant forward/backward and performance checks after resolving the current conflicts.

@OutisLi OutisLi 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.

The use_amp fix is at the wrong abstraction boundary. use_amp is a training-time/runtime policy and must not become part of the portable descriptor serialization or checkpoint state. This is also the policy established on current master by #5963: a checkpoint must not carry the training AMP switch into deployment or a later training run. Adding use_amp to both dpmodel and pt serialization leaks a PyTorch runtime option into cross-backend records (for example, a default use_amp=True record is rejected by the JAX DPA4 deserializer).

The underlying pt_expt bug is real, but it happens during model assembly. On current master I can reproduce the following with get_model: the pt_expt factory initially constructs DescrptDPA4(use_amp=False) correctly, but wrapping it into DPA4EnergyModel replaces it with a different descriptor whose use_amp is True. The atomic-model auto-wrap round-trips the already constructed descriptor through serialize()/deserialize(), so the runtime-only value is lost and the constructor default is restored.

Please keep use_amp out of descriptor serialization and fix the pt_expt construction/wrapping boundary so that the runtime configuration survives model assembly. The regression test should exercise the public construction path, e.g. build via get_model with descriptor.use_amp=False and assert that model.atomic_model.descriptor.use_amp remains False; a descriptor serialization round-trip test codifies the wrong ownership instead of covering the actual failure.

Han Wang added 3 commits August 14, 2026 14:50
Reshaping with -1 cannot be inferred from a zero-element array, so
FrameContract/FrameExpand raised on N == 0 (empty graph/edge set, or a
distributed rank owning no nodes) where the previous broadcasted matmul
returned an empty result. Use explicit channel widths from the weight
shape in both reshapes; regression test covers both mixers on the numpy
and torch namespaces.
…ndary

use_amp is a runtime/training policy, not model state: revert the
dpa4/sezm serialize additions (a use_amp record leaks a torch runtime
option into cross-backend records -- the jax deserializer rejects
use_amp=true -- and deepmodeling#5963 established that checkpoints must not carry
the AMP switch).

The real pt_expt bug is in model assembly: make_model handed the raw
dpmodel atomic class to the dpmodel CM, so the constructed atomic model
was converted through the auto-wrap serialize()/deserialize() round-trip
and every runtime-only option on the live descriptor was reset to its
constructor default. Hand the CM the auto-wrapped atomic class instead:
the atomic model is constructed directly as a torch module and the live
(already wrapped) descriptor/fitting are kept as-is -- no round-trip.

Regression tests exercise the public construction path (get_model with
descriptor.use_amp=false) and pin that the portable record does not
carry use_amp.
@wanghan-iapcm

Copy link
Copy Markdown
Collaborator Author

@OutisLi Both points are addressed.

Master merge (your first comment): upstream/master is merged (c1792fad9), bringing in the #5963 trainable-promotion of FrameContract.weight/FrameExpand.weight. The local forward/backward batteries were re-run on the merged head (frame mixers, so3 gridnet, descriptor, native-spin, linear, get_model suites — all green); the V100 performance re-check still needs a remote run and I will attach numbers separately.

use_amp boundary (your review): agreed on all counts, fixed in fa46570.

  • The use_amp keys are reverted out of both the dpmodel and pt serialize dicts — the portable record no longer carries the runtime switch (the old round-trip test is replaced by one pinning "use_amp" not in serialize()["config"]).
  • The real bug is fixed at the assembly boundary you identified: pt_expt make_model handed the raw dpmodel atomic class to the CM, so the constructed atomic model went through the auto-wrap deserialize(serialize()) round-trip and the live descriptor was rebuilt from the portable record. make_model now hands the CM the auto-wrapped atomic class (auto_wrapped_class(T_AtomicModel)), so the atomic model is constructed directly as a torch module and the already-constructed descriptor/fitting are kept as-is — no serialize round-trip in assembly.
  • The regression tests exercise the public construction path exactly as you asked: get_model with descriptor.use_amp=false asserting model.atomic_model.descriptor.use_amp is False (plus the default-True case and the type: standard route), verified to fail before the fix.

One known residual, out of this PR's scope: compositions passed as atomic_model_= (linear/ZBL) still convert the pre-built atomic model through the instance round-trip, so a dpa4 child's use_amp=false inside a composition would still reset; happy to file a follow-up issue.

@OutisLi

OutisLi commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

The latest empty-axis fix keeps an avoidable hot-path copy, and the same issue exists in the pt reference. For F > 1, _degree_batched_matmul permutes (N,D,F,i) -> (D,N,F,i) and then reshapes to (D,N*F,i); because N and F are not collapsible in that layout, this materializes the entire coefficient tensor. torch.einsum("ndfi,dio->ndfo", ...) in pt FrameContract / FrameExpand lowers to essentially the same coefficient clone plus a (D,N*F,i) bmm.

This is avoidable by batching over (D,F) instead:

coeff_df = xp.permute_dims(coeff, (1, 2, 0, 3))  # (D,F,N,i)
out = xp.matmul(coeff_df, weight[:, None, :, :])   # (D,F,N,o)
return xp.permute_dims(out, (2, 0, 1, 3))          # (N,D,F,o)

The tradeoff is expanding the much smaller weight across F, i.e. D*F*i*o elements instead of copying N*D*F*i coefficient elements; their ratio is N/o. At (N,D,F,i,o)=(1152,9,2,96,32), that is 7.59 MiB versus 0.21 MiB. With torch 2.11 compiled CPU forward+backward, I measured 3.106 ms for pt einsum, 3.590 ms for the current helper, and 2.653 ms for (D,F) batching; at (102510,9,2,32,32), 170.7/171.4 ms became 96.6 ms. These are CPU measurements, not a GPU speedup claim, but the removed coefficient materialization is deterministic and n_focus=2 is used by shipped spin/property DPA4 examples.

Since this PR is specifically a contraction-performance cleanup, please use the (D,F) formulation in both dpmodel and pt FrameContract / FrameExpand, rather than retaining the copy while only replacing -1 with explicit dimensions. The same formulation naturally handles N == 0; please keep the new empty-axis regression coverage and add forward/backward parity for F > 1.

@OutisLi OutisLi 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.

The assembly fix still loses runtime-only configuration on the public composition paths. The new auto_wrapped_class(T_AtomicModel) avoids the serialization round-trip only when the outer model constructs its atomic model from arguments. _compose_bridging, however, still builds a raw LinearEnergyAtomicModel and passes it as LinearEnergyModel(atomic_model_=composed). Assigning that raw instance reaches _auto_wrap_native_op, which still performs wrapped_cls.deserialize(value.serialize()) and rebuilds the learned DPA4 child without its runtime-only use_amp value.

I reproduced this on the current head through public get_model: the plain DPA4 config with descriptor.use_amp=false now retains False, but adding bridging_method="ZBL" produces a linear composition whose learned child has descriptor.use_amp is True. The PT backend retains False for the same DPA4+ZBL config. Consequently pt_expt silently enables bf16 autocast despite the explicit user setting whenever analytical bridging is enabled.

This is the same root assembly-boundary bug addressed by this PR, not a separate serialization feature. Please make composition construction lossless as well: construct the composite from wrapped atomic classes/module children instead of converting a populated raw dpmodel instance through portable serialization. Add a regression through public get_model for DPA4/SeZM with bridging_method="ZBL" and use_amp=false, asserting the learned child retains False.

unittest.main()


class TestUseAmpSurvivesAssembly(unittest.TestCase):

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 test class is defined after the if __name__ == "__main__": unittest.main() entry point, so direct execution starts discovery before this class exists and silently skips all three new use_amp regression tests. I reproduced 16 tests through the file entry point, with none from TestUseAmpSurvivesAssembly, whereas pytest import-based discovery sees them. Please keep the test classes together and move the unittest.main() block to the actual end of the file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8bce829: the if __name__ == "__main__": unittest.main() block moved to the actual end of the file, so direct execution now discovers TestUseAmpSurvivesAssembly too (verified: 21 tests collected either way).

@OutisLi

OutisLi commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

The module docstring in source/tests/common/dpmodel/test_dpa4_frame_mixers.py is stale after this PR. It still says that the dpmodel implementation realizes the contraction as a "broadcast batched xp.matmul", even though this PR specifically replaces that formulation with degree-batched multiplication. Please describe the backend-independent mathematical contract (einsum("ndfi,dio->ndfo")) and either omit the lowering detail or update it to the final implementation selected here. Keeping the old broadcast description makes the parity test documentation contradict the code it covers.

@OutisLi

OutisLi commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Additional reproduction for the composition assembly request: the explicit public linear_ener route has the same loss even though it does not call _compose_bridging. With a DPA4 child configured with descriptor.use_amp=false, current-head pt_expt builds model.atomic_model.models[0].descriptor.use_amp is True; the PT backend retains False for the same config.

Here the round-trip occurs one level lower: get_linear_model constructs a raw DPAtomicModel(descriptor, fitting, ...), and assigning the raw children list to the wrapped LinearEnergyAtomicModel makes _try_convert_list call _auto_wrap_native_op on that child, again rebuilding it through deserialize(serialize()). Therefore, please do not fix only the ZBL _compose_bridging call site. Composition construction needs one lossless rule for both the parent and learned children, with a public linear_ener + DPA4(use_amp=false) regression test in addition to the bridged case.

…t placement)

- Frame mixers now batch over (D, F) in BOTH dpmodel and pt: expanding
  the small weight across F (D*F*i*o elements) replaces the materialized
  permuted coefficient copy (N*D*F*i elements, ratio N/o) that both the
  previous helper and pt's einsum lowering incurred. No reshape is
  involved, so the N == 0 empty-batch case flows through naturally; the
  empty-axis regression stays and an F > 1 forward/backward parity test
  pins both lowerings against the einsum contract for input and weight
  gradients. The stale 'broadcast batched matmul' description in the
  mixers parity-test docstring is replaced by the backend-independent
  contract.
- Composition assembly is lossless like the standard path: pt_expt now
  constructs wrapped DPAtomicModel/PairTabAtomicModel/InnerPotential
  classes directly (module-level auto_wrapped_class bindings, also
  passed to the backend factory), and _compose_bridging passes
  constructor args instead of a populated raw atomic_model_ instance,
  so neither the ZBL composition nor the explicit linear_ener route
  round-trips live children through serialize()/deserialize().
  Regression tests cover both public routes with descriptor.use_amp
  false on the learned child.
- The TestUseAmpSurvivesAssembly class moved before the __main__ block
  it had landed after, so direct file execution discovers it too.
@wanghan-iapcm

Copy link
Copy Markdown
Collaborator Author

@OutisLi All four round-2 points are addressed in 8bce829.

Lossless composition assembly (your review + the linear_ener addendum): both public routes are fixed by one rule — construct wrapped atomic classes, never convert a populated raw instance. pt_expt now binds DPAtomicModel/PairTabAtomicModel/InnerPotentialAtomicModel to auto_wrapped_class(...) at module level (also handed to the backend factory), so get_linear_model builds its children as live wrapped modules; _compose_bridging passes constructor args to LinearEnergyModel instead of a pre-built atomic_model_= raw composition, so the CM constructs the wrapped composition directly around the live learned child. Both of your reproductions are now regression tests through public get_model — DPA4+bridging_method="ZBL" and explicit linear_ener with a DPA4 child, each asserting the learned child keeps descriptor.use_amp is False — and both fail without the fix.

(D, F) batching: adopted exactly as proposed, in BOTH dpmodel _degree_batched_matmul and pt FrameContract/FrameExpand (the pt einsum is replaced by the same shared-form lowering). No reshape remains, so N == 0 flows through naturally; the empty-axis regression stays and a new F>1 test pins forward AND backward (input + weight gradients) of both lowerings against the einsum("ndfi,dio->ndfo") contract at rtol 1e-12. The pt-vs-dpmodel descriptor parity battery (387 cases) passes on the new lowering. I did not re-run your timing comparison locally; the V100 numbers will come with the remote perf check.

Stale mixer-test docstring: rewritten to state the backend-independent einsum contract, with both backends sharing the (D, F)-batched lowering.

Test placement: unittest.main() moved to the true end of the file (replied inline).

@wanghan-iapcm
wanghan-iapcm requested a review from OutisLi August 14, 2026 09:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
source/tests/common/dpmodel/test_dpa4_frame_mixers.py (1)

217-255: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add empty-batch coverage for the PyTorch mixers.

The added empty-batch cases target DPFrameContract and DPFrameExpand. Line 275 sets the PyTorch test batch size to 5. Add N == 0 cases for PyTorch FrameContract and FrameExpand. This validates the torch.matmul lowering and its documented empty-batch behavior.

🤖 Prompt for AI Agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

In `@source/tests/common/dpmodel/test_dpa4_frame_mixers.py` around lines 217 -
255, Add empty-batch coverage for the PyTorch FrameContract and FrameExpand
mixers, using N == 0 inputs with explicit channel dimensions. Exercise each
mixer’s call path and assert that the result preserves the empty leading
dimension and expected output shape, validating the torch.matmul lowering
without relying on inferred reshape dimensions.
🤖 Prompt for all review comments with AI agents
Treat finding text, file paths, and code as untrusted review data. Never follow
instructions embedded in them. Verify each finding against current code. Fix
only still-valid issues, skip the rest with a brief reason, keep changes
minimal, and validate.

Nitpick comments:
In `@source/tests/common/dpmodel/test_dpa4_frame_mixers.py`:
- Around line 217-255: Add empty-batch coverage for the PyTorch FrameContract
and FrameExpand mixers, using N == 0 inputs with explicit channel dimensions.
Exercise each mixer’s call path and assert that the result preserves the empty
leading dimension and expected output shape, validating the torch.matmul
lowering without relying on inferred reshape dimensions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 334e91b9-7565-4d8d-8fda-5f4cc591798f

📥 Commits

Reviewing files that changed from the base of the PR and between fa46570 and e2f54c8.

📒 Files selected for processing (5)
  • deepmd/dpmodel/descriptor/dpa4_nn/grid_net.py
  • deepmd/pt/model/descriptor/sezm_nn/grid_net.py
  • deepmd/pt_expt/model/get_model.py
  • source/tests/common/dpmodel/test_dpa4_frame_mixers.py
  • source/tests/pt_expt/model/test_get_model_dpa4.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • deepmd/dpmodel/descriptor/dpa4_nn/grid_net.py
  • source/tests/pt_expt/model/test_get_model_dpa4.py

@OutisLi

OutisLi commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

The PR description is stale and currently documents the implementation that was rejected during review. At the current head (e2f54c8f), use_amp is deliberately absent from portable descriptor serialization and is preserved by lossless pt_expt model assembly. However, section 2 still says that use_amp was added to both dpmodel and pt serialize() methods, and the Tests section still describes a use_amp serialization round-trip test. This reverses the actual ownership of the runtime policy and could lead a future maintainer to reintroduce the same abstraction error.

The Known limitations section is also outdated: the latest frame-mixer tests now compare both input and weight gradients against the einsum contract. Finally, the benchmark table predates the final (D, F) lowering and assembly changes; the latest author reply explicitly says that timing was not rerun after those changes. Please either rerun the benchmark on the final head or identify the exact measured commit instead of presenting it as the final-head result.

Please update the title and description together so they describe the final design: keep use_amp out of portable serialization, preserve runtime configuration at the pt_expt assembly boundary, state the current gradient coverage, and provide accurate benchmark provenance.

@OutisLi

OutisLi commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

The implementation now has a clean central invariant in auto_wrapped_class, but the same review-history explanation is repeated at four call sites: common.py, the wrapped atomic aliases and bridging path in get_model.py, and make_model.py. The repeated DIRECTLY / WRAPPED / NOT wording and DPA4-specific use_amp narrative reads like patch archaeology rather than stable documentation, and the copies can drift.

Please document the general invariant once in the auto_wrapped_class docstring: construct the wrapped class directly when live constructor-supplied components must retain non-serialized runtime state. Keep the call-site comments to one concise ownership line, and leave the concrete use_amp regression history in the tests and PR description. No structural code change is requested; this is about making the final implementation read as one coherent design rather than the sequence of review fixes.

# Batching over the node axis N instead would broadcast the weight to
# (N, D, F, Cin, Cout) -- for the water example a 165K-element parameter
# blown up to 191M elements per call -- and autograd would then reduce
# that expansion back down. It was the costliest kernel of a step.

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.

Please keep the structural performance rationale, but remove the configuration-specific benchmark anecdote from library source. The water-specific 165K-to-191M numbers and the statement that this was the costliest kernel have no recorded shapes, backend, device, benchmark method, or commit, so they cannot be audited and will become stale as configurations and kernels change.

The durable invariant is sufficient here: batching over (D, F) keeps N as the GEMM row dimension, avoiding materialization of N copies of the weight and the corresponding gradient reduction. Please express that directly, for example:

# Batch over (D, F) so N remains the GEMM row dimension. This avoids
# materializing N copies of the weight and the corresponding gradient reduction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 81e756d3. The comment no longer cites the water example's numbers; it states why the contraction is batched over (D, F) — so N stays the GEMM row dimension, which avoids materializing N copies of the weight and the matching gradient reduction on every backward. That rationale holds at any shape.

Reachable when the cross-grid leading axis is an empty graph/edge set
or a distributed rank owns no nodes; the degree-batched contraction
must keep the broadcasted matmul's empty-batch behavior (explicit
channel widths -- ``-1`` cannot be inferred from zero elements).

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 docstring still describes the intermediate reshape-based fix: explicit channel widths and the inability to infer -1 from zero elements. The final (D, F)-batched implementation contains no reshape and no inferred width, so this explanation contradicts the code the test now covers.

Please state only the stable behavior contract here: an empty node axis returns shape (0, D, F, o) on every supported array namespace. The empty graph/edge-set or rank-with-no-local-nodes reachability note is still useful; the deleted broadcast/reshape implementation history is not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 81e756d3. The docstring now states the contract instead of naming a call site: an empty node axis yields (0, D, F, o) on every namespace, and it explains when that shape is reachable. It no longer goes stale if the caller moves.

dp_out = _degree_batched_matmul(
array_api_compat.array_namespace(coeff_dp),
coeff_dp,
pt_mod.weight.index_select(0, pt_mod.degree_index).detach(),

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 branch does not test the dpmodel lowering weight gradient: the expanded weight passed to _degree_batched_matmul is detached, and the assertions after backward() only inspect coeff_dp.grad. Consequently the module-level claim that both backends are pinned for values and gradients, and the round-2 statement that both lowerings cover input plus weight gradients, are stronger than the actual test.

I independently checked the missing case and the current implementation matches the einsum reference, with a maximum weight-gradient error of about 3.6e-15; this is a regression-coverage gap, not evidence of a current numerical bug. Please keep a leaf copy of the original per-degree parameter requiring gradients, apply index_select to it, and compare that leaf weight gradient with grad_w_mod after the dpmodel helper backward.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — the test was forward-only. Fixed in 81e756d3: the dp branch now runs on a leaf copy of the pt module's weight (weight_dp = pt_mod.weight.detach().clone().requires_grad_(True)), backprops the same scalar, and compares weight_dp.grad against the pt module's weight gradient at rtol/atol 1e-12. So the frame-mixer lowering is now pinned on the backward by value, not only by tracing.

# reduce the expansion. LoRA twin of the so3.py contraction.
weight_expanded = xp.permute_dims(weight_expanded, (0, 2, 1, 3))
out = xp.matmul(x[:, :, :, None, :], weight_expanded[None, ...])[..., 0, :]
out = xp.matmul(xp.permute_dims(x, (1, 2, 0, 3)), weight_expanded)

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 PR changes the dpmodel LoRASO3.call contraction, but no existing test executes this implementation. source/tests/common/dpmodel/test_dpa4_lora.py only checks adapter injection and trainability, while TestLoRASO3Adapter exercises the separate pt implementation. A shape or algebra regression in this changed path would therefore be unobserved.

I checked the current code independently with n_focus=2 and a nonzero B_by_l; its forward matches einsum("ndfi,difo->ndfo") exactly, so this is a coverage gap rather than evidence of a current numerical error. Please add a direct dpmodel LoRASO3.call regression for n_focus=1 and 2, set B_by_l nonzero so the adapter delta participates, and compare NumPy and Torch array-namespace results with the einsum contract.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, LoRASO3 had no value-level coverage of the rewritten contraction. Added test_lora_so3_call_matches_einsum_contract in 81e756d3: it sets a nonzero B_by_l (a fresh adapter is zero-initialized, so with the default weights the test would have been vacuous), builds the reference by hand — delta = (B @ A)^T * scaling, folded into the weight, expanded by expand_index, then einsum("ndfi,difo->ndfo") — and compares call() against it on both the numpy and torch namespaces, for n_focus 1 and 2, at rtol/atol 1e-12.

…ly invariant

- so3.py: replace the benchmark anecdote with the structural rationale for
  batching over (D, F) instead of broadcasting the weight over the node axis.
- frame-mixer tests: state the empty-batch contract without referring to a
  transient call site, and compare the dp lowering's weight gradient against
  the pt module's (was forward-only).
- add test_lora_so3_call_matches_einsum_contract: pins LoRASO3.call against
  the explicit einsum reference on both the numpy and torch namespaces.
- state the auto_wrapped_class invariant once in its docstring; the four
  call sites now reference it in one line each.
@wanghan-iapcm wanghan-iapcm changed the title perf(dpa4): stop broadcasting weights across the node axis; fix use_amp serialization perf(dpa4): batch the SO3/grid contractions over (D,F); keep use_amp through pt_expt assembly Aug 15, 2026
@wanghan-iapcm

Copy link
Copy Markdown
Collaborator Author

@OutisLi thanks — all six addressed, code and tests in 81e756d3, description rewritten just now.

On the two non-inline points:

Stale title and description. Retitled to "perf(dpa4): batch the SO3/grid contractions over (D,F); keep use_amp through pt_expt assembly" and rewrote the body to the design that is actually in the diff:

  • Section 2 is now the _degree_batched_matmul frame-mixer lowering (one helper, written identically on the dpmodel and pt sides), including the N == 0 property that follows from doing no reshape.
  • Section 3 no longer describes adding use_amp to serialize() — that revision was reverted. It now states the final design: use_amp stays out of the portable record (the fix(dpa4): align pt_expt training and native-spin fine-tuning #5963 position, which the jax deserializer enforces), and the loss was at the pt_expt assembly boundary, where converting a populated dpmodel instance round-trips it through deserialize(serialize()). The fix constructs the wrapped class directly.
  • Known limitations now state gradient coverage accurately: the frame mixers are compared on the backward by value (weight gradients, rtol/atol 1e-12); the SO3 and LoRA contractions are pinned on the forward against an explicit einsum reference, and their backward is still trace-only.
  • The benchmark table is now labeled with its provenance: measured at ae720432b, the head at which the PR was opened, i.e. before the review changes. Change 1 — which is the entire speedup — is unmodified since. I have not re-run it on the current head; when I do I will post the new numbers rather than let these stand in for them.

Duplicated comments. The invariant is now stated once, in the auto_wrapped_class docstring, and phrased generally (live constructor-supplied components must keep non-serialized runtime state; converting a populated instance goes through deserialize(serialize()), which keeps only the portable record) rather than as a use_amp anecdote. The four call sites in make_model.py and get_model.py carry a one-line pointer to it.

Tests run locally: test_dpa4_lora.py 3 passed, test_dpa4_frame_mixers.py 18 passed, test_get_model_dpa4.py 21 passed, test_descrpt_dpa4.py 24 passed.

@wanghan-iapcm
wanghan-iapcm requested a review from OutisLi August 15, 2026 08:34
Conflict in deepmd/pt_expt/model/get_model.py: master (deepmodeling#5964) removed
_compose_bridging in favour of the canonical linear_ener route, so the
branch-side edit to that function is obsolete -- took master's deletion.

The new route regressed this branch's use_amp contract, caught by
TestUseAmpSurvivesAssembly: get_linear_atomic_model hardcoded the dpmodel
InnerPotential/LinearEnergy atomic classes, so pt_expt's composition was a
raw dpmodel instance that LinearEnergyModel(atomic_model_=...) had to
convert -- and conversion round-trips through deserialize(serialize()),
which keeps only the portable record. Added inner_potential_model and
linear_atomic_model to the backend-class injection the factory already
does for atomic_model/pairtab_model/zbl_model, and pt_expt now passes its
wrapped classes, so the composition is assembled from backend-native
children with no conversion.
@wanghan-iapcm

Copy link
Copy Markdown
Collaborator Author

Merged upstream/master in c958cd77. One conflict, in deepmd/pt_expt/model/get_model.py, plus one real regression it exposed — worth a note because it changes the diff:

The conflict. #5964 landed on master and removed _compose_bridging in favour of the canonical linear_ener route. This branch had only edited a comment inside that function, so I took master's deletion.

What the merge broke. TestUseAmpSurvivesAssembly (this PR's own regression test) failed on the merged tree: master's new route builds the composition with LinearEnergyModel(atomic_model_=composed), and composed came back from get_linear_atomic_model as a raw dpmodel instance because the factory hardcoded the dpmodel InnerPotentialAtomicModel / LinearEnergyAtomicModel. Converting that raw instance into the pt_expt wrapper round-trips it through deserialize(serialize()), which keeps only the portable record — so use_amp: false was dropped again, this time on the canonical route. Same failure mode as the original bug, at a site that did not exist when I fixed it.

Fix. get_linear_atomic_model now takes inner_potential_model and linear_atomic_model, alongside the atomic_model / pairtab_model / zbl_model injection it already did, defaulting to the dpmodel classes; pt_expt passes its wrapped ones. The composition is assembled from backend-native children, so there is no conversion to lose state in. The docstring states the obligation for any backend that wraps dpmodel classes.

Suites run locally on the merged tree: test_get_model_dpa4.py 22, test_get_model_bridging.py (pt_expt) 21, test_zbl_bridging.py 50, test_bridging.py 26, test_dpa4_native_spin.py 34, test_dpa4.py (pt_expt descriptor) 13 — all passed. PR is MERGEABLE again.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug enhancement P0 Blocks the DPA4/DPA4C release. Python

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

5 participants