Skip to content

EquivariantLinear: one row-major block-diagonal GEMM — no layout churn - #14

Merged
alacour merged 13 commits into
mainfrom
perf/equivariant-linear-blockgemm
Sep 4, 2026
Merged

alacour merged 13 commits into
mainfrom
perf/equivariant-linear-blockgemm

Conversation

@alacour

@alacour alacour commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

torch.profiler (via #13's --torch_profile) showed aten::copy_ at ~45 ms/step — 29% of the joint force step — traced to EquivariantLinear's einsum: it lowers to a batched bmm over the angular axis, which copies the full per-edge activation for batch contiguity on every call and emits an m-major output that every consumer pays again to re-normalize.

Since the map y[e,o,m] = Σᵢ x[e,i,m]·W[m,o,i] never mixes angular modes, it is now one dense row-major GEMM over the flattened (feature, angular) axis against a per-m block-diagonal weight assembled from the unchanged parameter each call (bias in the addmm epilogue on the m=0 cos slots). No permutes, no copies, and the row-major output is exactly the layout the fused RealSpace kernel's fast path wants. The zero blocks cost n_ang× the strictly needed flops, absorbed by TF32 tensor cores. Parameters/state_dict unchanged — existing checkpoints load as before.

Measured (A100, 512-atom diamond box, 44k edges, float32+TF32, all fusion flags):

Test plan

  • New tests/test_equivariant_linear.py: forward, input/weight/bias gradient, non-contiguous m-major input, and batched-leading-dim equivalence vs the einsum reference — exact at fp64.
  • Existing suites pass unchanged: realspace kernel, bottleneck, attention MP, ecenet, edge-frame kernel, calculator, LES.

🤖 Generated with Claude Code

https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK

alacour and others added 2 commits September 3, 2026 18:27
torch.profiler on the joint force step (512-atom box, A100) showed
aten::copy_ at ~45 ms/step — 29% of everything, 312 copies — traced to this
layer's einsum: it lowers to a batched bmm over the angular axis, whose
batch-contiguity requirement copies the full per-edge activation on every
call and emits an m-major output that every consumer (the fused
nonlinearity's wrapper, residual adds, the next einsum) pays again to
re-normalize.

The map y[e,o,m] = sum_i x[e,i,m] W[m,o,i] never mixes angular modes, so it
is now evaluated as one dense row-major GEMM over the flattened
(feature, angular) axis against a per-m block-diagonal (in*n_ang, out*n_ang)
weight assembled from the parameter each call (MB-scale; autograd flows
through the assembly; bias enters the addmm epilogue on the m=0 cos slots).
The zero blocks cost n_ang-times the strictly needed flops, which
TF32/tensor cores absorb; in exchange there are no permutes, no copies, and
the row-major output is exactly the layout the fused RealSpace kernel's
fast path wants, so its fallback copies disappear too. Parameters and
state_dict are unchanged — existing checkpoints load as before.

tests/test_equivariant_linear.py pins the new forward to the einsum
reference: forward, input/weight/bias gradients, non-contiguous m-major
inputs, batched leading dims — all exact at fp64. Existing suites
(realspace kernel, bottleneck, attention MP, ecenet, edge-frame kernel,
calculator, LES) pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
…bsorb it

The dense block-diagonal GEMM pays 3x flops for its zero blocks. With TF32
that padding is free and removing the einsum layout churn nets 159.6 -> 111.4
ms on the joint step; on fp32 CUDA cores with TF32 off it costs exactly 3x
(linear_down 2.1 -> 5.6 ms measured) and regressed the strict-fp32 step to
244.8 ms, and float64 — the calculator default — would pay the same. Dispatch
is now automatic: dense for CUDA fp16/bf16 and for CUDA fp32 with TF32
enabled, the original einsum path otherwise; dense_gemm=True/False overrides.
Tests force the dense path (auto picks einsum on CPU) and cover the dispatch
logic on both CPU and CUDA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
@alacour

alacour commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up commit 8fe30b6: the dense GEMM's 3× zero-block padding is only free on tensor cores — with TF32 off it cost exactly 3× on the fp32 CUDA-core path (joint step 218.6 → 244.8 ms regression), and float64 (the calculator default) would pay the same. Dispatch is now automatic: dense for CUDA fp16/bf16 and for CUDA fp32 with TF32 enabled; the original einsum path otherwise (dense_gemm=True/False overrides). New test_dispatch covers the logic on CPU and CUDA.

Verification on GPU: --float32 --tf32 should stay at ~111 ms joint; --float32 without --tf32 should return to ~218 ms (einsum path).

alacour and others added 8 commits September 3, 2026 18:45
Without it a float32 ecenet benchmark silently takes the einsum path (the
dense EquivariantLinear GEMM auto-dispatches only when allow_tf32 is set
in-process) and the CSV could not distinguish TF32 runs. The flag sets the
torch backend switches process-wide and stamps '+tf32' into the dtype
column. DPA runs are unaffected by torch flags (DeePMD precision is set via
DP_TF32_INFER in its own environment) — noted in the help text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
… precision

The dipole/BEC evals could only run at the checkpoint's native matmul
precision; with the TF32 benchmarking path there was no way to measure what
TF32 costs those observables. The flag mirrors profile_step/
benchmark_calculator: sets the torch backend switches, warns when the device
is not CUDA.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
Completes the TF32 story: the energy/force test-set evaluation (the headline
accuracy axis) can now run at the same precision as the benchmarks. Same
backend switches as benchmark_calculator/profile_step; warns when dtype is
float64, and the printed dtype line records +tf32.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
The edge-frame family is the largest remaining kernel cost (~25 ms/step at
512 atoms: _ef_bwd_merged 2.33 ms/call, _pu_bwd_merged 1.98, fwd ~1.0), all
running ~3.5x above their traffic bound. Unlike the RealSpace case the loads
are already coalesced tiles, so the gap is some mix of padded ieee tl.dot
arithmetic (9 valid of 16 in both dot dims), 36-byte row misalignment, and
per-edge program overhead — not separable without measurement. First step:
make warps-per-program env-tunable (default 4, the previous implicit value)
on all ten launch sites so the cheap axis can be swept before any redesign.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
ncu on the merged backward kernels (A100, 512-atom box, 44k edges) settles
the question the flat warps sweep left open: L1/TEX throughput 75-86% with
DRAM at 9-18% and compute ~30% — the kernels are L1-bound, stalling on MIO
short-scoreboard, while DRAM idles. The amplification is the per-element
gathered D loads through the (l,m) column tables and the matching dD
scatter-stores: hundreds of non-vectorizable L1 transactions per program.

The packed variants spend the idle DRAM instead: the wrapper pre-packs D
into dense (E, S, P)/(E, P, S) tensors once per call (plain torch indexing,
~14 MB at 44k edges) and unpacks the packed dD afterwards, so the kernels
do only vectorized coalesced tile IO. Math and masks identical. Gated off
by default behind ECENET_EF_PACKD=1 (module flag, monkeypatchable) pending
an A/B on the A100; ncu's stall-fix estimate is ~33-37% on these kernels.

test_triton_packed_d reruns the full test_triton_paths comparison set (both
merged backwards, vs fp64 eager truth) with the flag on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
…block

The packed kernels won at the kernel level (ncu: _ef_bwd_merged 2.94 -> 1.68
ms, _pu_bwd_merged 2.49 -> 1.95 ms) but the joint step stayed flat: per-call
packing/transposing in the backward wrappers (~8 small torch ops x 7
backward calls/step) ate the entire ~5 ms win.

The model builds ONE D_block per step and hands the same Python object to
every fused edge-frame op, so _get_packed_D now caches the packed tensors as
an attribute on that object — packing runs once per step. Function forwards
fetch it and carry the tensors through save_for_backward (attributes do not
survive the re-wrapping); the backward wrappers receive them instead of
packing. A fresh step's fresh D_block starts clean, so no cross-step
staleness. Still gated behind ECENET_EF_PACKD=1 pending the A/B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
torch.profiler with the packed path on showed the GPU win arriving (packed
kernels 1.35/1.65 ms per call, CUDA total down) while wall time stayed flat,
and named the thief: EdgeFrameFusedBackward at 10.6 ms CPU per call, with
aten::index at 44% of CPU total. _unpack_dD's boolean-mask indexing
(cos_col[vc], dD[:, :, col[vc]]) forces a host-device sync on every backward
call — seven pipeline stalls per step. The masks are static, so the unpack
now uses integer index tensors precomputed once per (S, n_ang, device);
every op in the unpack is async.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
…ault on

With the sync-free unpack the packed backward finally reached the wall
clock: joint force step 111.4 -> 107.8 ms on the A100 512-atom box
(EdgeFrameFusedBackward CPU 10.6 ms -> 0.29 ms per call, aten::index off
the CPU hot list, packed kernels at 1.35/1.65 ms in-app).

This extends the same treatment to the forward side, which carries the
identical table-gather pattern: _ef_fwd_packed_kernel (EdgeFrameFused
forward, ~1.04 ms x 4/step) and _ef_bwd_dx_packed_kernel (PackUnrotate's
forward contraction, ~1.17 ms x 3/step) read the pre-packed Dc/Ds / DcT/DsT
tiles instead of gathering through the column tables; amortization is free
since the packed tensors already live on the shared per-step D_block.

Default flipped ON (ECENET_EF_PACKD=0 restores the table-gather kernels);
test_triton_packed_d now runs the full fp64 comparison suite under both
settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
alacour and others added 3 commits September 3, 2026 20:15
Same kwarg and enable block as train_ecenet_spice / train_ecenet_xyz: routes
float32 matmuls to TF32 tensor cores, warns under float64, and reminds to
A/B the val MAE. Completes the TF32 story across training, evaluation, and
benchmarking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019WyADHSVvKbQnYo1PAjGPK
Edge-frame kernels: packed-D tile IO (measurement-driven), sweepable warps
@alacour
alacour merged commit f9d40af into main Sep 4, 2026
1 check passed
@alacour
alacour deleted the perf/equivariant-linear-blockgemm branch September 4, 2026 03:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant