Skip to content

Commit 99d33ea

Browse files
author
Han Wang
committed
feat(pt_expt): honor enable_tf32 / DP_TF32_INFER like the pt backend
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.
1 parent 3c2b9bf commit 99d33ea

4 files changed

Lines changed: 233 additions & 58 deletions

File tree

deepmd/pt_expt/model/get_model.py

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import copy
1010
import logging
11+
import os
1112

1213
from deepmd.dpmodel.atomic_model.dp_atomic_model import (
1314
DPAtomicModel,
@@ -52,8 +53,49 @@
5253

5354
log = logging.getLogger(__name__)
5455

55-
# Warn at most once per process for backend-ignored switches (keyed by name).
56-
_WARNED_ONCE: set[str] = set()
56+
#: ``DP_TF32_INFER`` -> eval-time matmul precision, copied from the pt backend's
57+
#: ``deepmd.pt.model.model.sezm_model._TF32_INFER_PRECISION_CHOICES`` so the two
58+
#: backends read the same environment variable the same way.
59+
_TF32_INFER_PRECISION_CHOICES = {
60+
"0": "highest",
61+
"1": "high",
62+
"2": "medium",
63+
}
64+
65+
66+
def _apply_tf32_policy(model: BaseModel, data: dict) -> BaseModel:
67+
"""Attach the DPA4/SeZM TF32 matmul-precision policy to a built model.
68+
69+
Mirrors the pt backend: TRAINING forwards follow ``model.enable_tf32``
70+
(default ``True``) and EVAL forwards follow ``DP_TF32_INFER``. The model
71+
layer applies the policy -- see ``call_common`` in
72+
:func:`deepmd.pt_expt.model.make_model.make_model` and
73+
``_CompiledModel.forward`` for the compiled path.
74+
75+
Parameters
76+
----------
77+
model : BaseModel
78+
The freshly built model to configure.
79+
data : dict
80+
The model config section, read for ``enable_tf32``.
81+
82+
Returns
83+
-------
84+
BaseModel
85+
The same model, with the precision policy attached.
86+
87+
Raises
88+
------
89+
ValueError
90+
If ``DP_TF32_INFER`` is set to anything other than ``0``, ``1``, or
91+
``2``.
92+
"""
93+
model.enable_tf32 = bool(data.get("enable_tf32", True))
94+
tf32_infer_env = os.environ.get("DP_TF32_INFER", "0").strip().lower()
95+
if tf32_infer_env not in _TF32_INFER_PRECISION_CHOICES:
96+
raise ValueError(f"DP_TF32_INFER must be one of 0/1/2, got {tf32_infer_env!r}")
97+
model.tf32_infer_precision = _TF32_INFER_PRECISION_CHOICES[tf32_infer_env]
98+
return model
5799

58100

59101
_model_factory = BackendModelFactory(
@@ -82,17 +124,11 @@ def get_sezm_model(data: dict) -> EnergyModel:
82124
83125
Notes
84126
-----
85-
``enable_tf32`` is accepted but ignored: the pt backend uses it to toggle
86-
TF32 matmul precision, while the pt_expt backend always runs at full
87-
("highest") matmul precision, which is numerically conservative.
127+
``enable_tf32`` follows the pt backend: TRAINING forwards run at TF32
128+
("high") matmul precision when it is true (the default), while EVAL
129+
forwards follow ``DP_TF32_INFER``. See :func:`_apply_tf32_policy`.
88130
"""
89131
data = copy.deepcopy(data)
90-
if bool(data.get("enable_tf32", True)) and "enable_tf32" not in _WARNED_ONCE:
91-
log.warning(
92-
"`enable_tf32` has no effect on the pt_expt backend, which "
93-
"always runs at full ('highest') matmul precision; ignoring it."
94-
)
95-
_WARNED_ONCE.add("enable_tf32")
96132
if "spin" in data:
97133
if str(data["spin"].get("scheme", "deepspin")) != "native":
98134
raise NotImplementedError(
@@ -192,8 +228,8 @@ def get_sezm_model(data: dict) -> EnergyModel:
192228
atom_exclude_types=data.get("atom_exclude_types", []),
193229
pair_exclude_types=pair_exclude_types,
194230
)
195-
return LinearEnergyModel(atomic_model_=composed)
196-
return model
231+
return _apply_tf32_policy(LinearEnergyModel(atomic_model_=composed), data)
232+
return _apply_tf32_policy(model, data)
197233

198234

199235
def get_native_spin_model(data: dict) -> NativeSpinEnergyModel:
@@ -257,7 +293,10 @@ def get_native_spin_model(data: dict) -> NativeSpinEnergyModel:
257293
"spin scheme 'native' requires an atomic model declaring "
258294
"supports_native_spin()"
259295
)
260-
return NativeSpinEnergyModel(atomic_model_=backbone_model.atomic_model, spin=spin)
296+
return _apply_tf32_policy(
297+
NativeSpinEnergyModel(atomic_model_=backbone_model.atomic_model, spin=spin),
298+
data,
299+
)
261300

262301

263302
def get_linear_model(model_params: dict) -> BaseModel:

deepmd/pt_expt/model/make_model.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
import contextlib
23
import math
34
import types
5+
from collections.abc import (
6+
Generator,
7+
)
48
from typing import (
59
Any,
610
)
@@ -435,6 +439,59 @@ def get_min_nbor_dist(self) -> float | None:
435439
"""Get the minimum distance between two atoms."""
436440
return self.min_nbor_dist
437441

442+
# === TF32 matmul precision ===================================
443+
# Mirrors the pt backend's ``SeZMModel`` policy (see
444+
# ``deepmd.pt.model.model.sezm_model.SeZMModel.tf32_precision_ctx``):
445+
# TRAINING forwards follow ``model.enable_tf32`` and EVAL forwards
446+
# follow ``DP_TF32_INFER``. Both attributes are set by the DPA4/SeZM
447+
# builders in ``deepmd.pt_expt.model.get_model``; every other pt_expt
448+
# model keeps these defaults, which select full fp32 in both modes and
449+
# therefore leave its numerics untouched.
450+
enable_tf32: bool = False
451+
tf32_infer_precision: str = "highest"
452+
453+
@contextlib.contextmanager
454+
def tf32_precision_ctx(self) -> Generator[None, None, None]:
455+
"""Select the matmul precision for one forward, then restore it.
456+
457+
Yields
458+
------
459+
None
460+
With ``torch.set_float32_matmul_precision`` set for the
461+
duration of the block.
462+
"""
463+
if not torch.cuda.is_available():
464+
yield
465+
return
466+
prev_precision = torch.get_float32_matmul_precision()
467+
try:
468+
if self.training:
469+
precision = "high" if self.enable_tf32 else "highest"
470+
else:
471+
precision = self.tf32_infer_precision
472+
torch.set_float32_matmul_precision(precision)
473+
yield
474+
finally:
475+
torch.set_float32_matmul_precision(prev_precision)
476+
477+
def call_common(self, *args: Any, **kwargs: Any) -> dict[str, torch.Tensor]:
478+
"""Run the shared dense/graph forward under the TF32 policy.
479+
480+
This is the ONE owner of matmul precision for eager forwards: every
481+
pt_expt model's ``forward`` reaches the backbone through here, and
482+
the export trace roots at ``call_common_lower`` instead, so the
483+
precision switch never enters an exported graph. The compiled
484+
training path bypasses this method entirely and applies the same
485+
policy at its own entry point (``_CompiledModel.forward``).
486+
487+
Returns
488+
-------
489+
dict[str, torch.Tensor]
490+
The backbone's output dict, unchanged.
491+
"""
492+
with self.tf32_precision_ctx():
493+
return super().call_common(*args, **kwargs)
494+
438495
def forward(self, *args: Any, **kwargs: Any) -> dict[str, torch.Tensor]:
439496
"""Default forward delegates to call().
440497

deepmd/pt_expt/train/training.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,24 @@ def __getattr__(self, name: str) -> Any:
919919
except AttributeError:
920920
return getattr(self.original_model, name)
921921

922-
def forward(
922+
def forward(self, *args: Any, **kwargs: Any) -> dict[str, torch.Tensor]:
923+
"""Run the compiled forward under the wrapped model's TF32 policy.
924+
925+
The compiled path never reaches ``call_common`` -- which owns matmul
926+
precision for eager forwards -- so it applies the same policy here, at
927+
its own entry point. The context also spans the LAZY compile below:
928+
Inductor selects its GEMM backend while lowering, so a precision set
929+
only around the call would never reach the generated kernels.
930+
931+
Returns
932+
-------
933+
dict[str, torch.Tensor]
934+
The model prediction dict.
935+
"""
936+
with self.original_model.tf32_precision_ctx():
937+
return self._forward_dispatch(*args, **kwargs)
938+
939+
def _forward_dispatch(
923940
self,
924941
coord: torch.Tensor,
925942
atype: torch.Tensor,

source/tests/pt_expt/model/test_get_model_dpa4.py

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -271,51 +271,113 @@ def test_default_unsupported_values_pass(self) -> None:
271271
self.assertIsInstance(model, EnergyModel)
272272

273273

274-
# `enable_tf32` toggles TF32 matmul precision in pt but is ignored by pt_expt
275-
# (always "highest" precision); a truthy value must emit a warn-once message.
276-
@pytest.mark.parametrize("enable_tf32", [True, False]) # truthy warns, falsy silent
277-
def test_enable_tf32_warns_once(enable_tf32, monkeypatch) -> None:
278-
import importlib
279-
280-
# the package __init__ rebinds the name ``get_model`` to the function, so
281-
# ``import ...get_model as`` would shadow the submodule; load it explicitly
282-
gm_mod = importlib.import_module("deepmd.pt_expt.model.get_model")
283-
284-
# reset the warn-once set so the assertion is deterministic regardless of
285-
# test ordering (other get_sezm_model calls may have already warned)
286-
monkeypatch.setattr(gm_mod, "_WARNED_ONCE", set())
287-
288-
# Count emissions on the EMITTING logger with our own handler rather than
289-
# through caplog: caplog reads a root handler, so whatever global logging
290-
# state earlier tests left behind (set_log_handles flips the ``deepmd``
291-
# logger's propagate off and installs its own handlers) changes how many
292-
# records reach it -- zero when propagation is off, more than one when the
293-
# record is seen through several attached handlers. A handler on the
294-
# emitting logger sees exactly one record per ``log.warning`` call.
295-
records: list[logging.LogRecord] = []
296-
297-
class _Collect(logging.Handler):
298-
def emit(self, record: logging.LogRecord) -> None:
299-
records.append(record)
300-
301-
handler = _Collect(level=logging.WARNING)
302-
old_level = gm_mod.log.level
303-
gm_mod.log.setLevel(logging.WARNING)
304-
gm_mod.log.addHandler(handler)
274+
# === TF32 matmul precision ==================================================
275+
# pt_expt mirrors the pt backend (``SeZMModel.tf32_precision_ctx``): TRAINING
276+
# forwards follow ``model.enable_tf32`` (default True) and EVAL forwards follow
277+
# ``DP_TF32_INFER``. The knob is DPA4/SeZM-scoped, matching pt, where argcheck
278+
# declares it inside the dpa4 model arg block.
279+
280+
281+
@pytest.mark.parametrize(
282+
"enable_tf32",
283+
[
284+
True, # the argcheck default; training must select TF32 ("high")
285+
False, # opt-out; training must stay at full fp32
286+
],
287+
)
288+
def test_enable_tf32_is_stored(enable_tf32) -> None:
289+
"""The config knob reaches the model instead of being warned away."""
290+
model = get_model(_make_raw_model_config(enable_tf32=enable_tf32))
291+
assert model.enable_tf32 is enable_tf32
292+
293+
294+
def test_enable_tf32_defaults_true() -> None:
295+
"""An absent key follows pt's ``default=True`` (argcheck.py `enable_tf32`)."""
296+
raw = _make_raw_model_config()
297+
assert "enable_tf32" not in raw
298+
assert get_model(raw).enable_tf32 is True
299+
300+
301+
@pytest.mark.parametrize(
302+
("env_value", "expected"),
303+
[
304+
(None, "highest"), # unset -> pt's "0" default, full fp32
305+
("0", "highest"),
306+
("1", "high"),
307+
("2", "medium"),
308+
],
309+
)
310+
def test_tf32_infer_precision_from_env(env_value, expected, monkeypatch) -> None:
311+
"""Eval precision follows ``DP_TF32_INFER``, as in the pt backend."""
312+
if env_value is None:
313+
monkeypatch.delenv("DP_TF32_INFER", raising=False)
314+
else:
315+
monkeypatch.setenv("DP_TF32_INFER", env_value)
316+
assert get_model(_make_raw_model_config()).tf32_infer_precision == expected
317+
318+
319+
def test_tf32_infer_precision_rejects_garbage(monkeypatch) -> None:
320+
"""An unusable ``DP_TF32_INFER`` fails fast rather than silently defaulting."""
321+
monkeypatch.setenv("DP_TF32_INFER", "yes")
322+
with pytest.raises(ValueError, match="DP_TF32_INFER"):
323+
get_model(_make_raw_model_config())
324+
325+
326+
@pytest.mark.parametrize(
327+
("enable_tf32", "training", "expected"),
328+
[
329+
(True, True, "high"), # the only combination that selects TF32
330+
(False, True, "highest"), # opt-out keeps training at full fp32
331+
(True, False, "highest"), # eval ignores enable_tf32 (uses DP_TF32_INFER)
332+
(False, False, "highest"),
333+
],
334+
)
335+
def test_tf32_precision_ctx_selects_and_restores(
336+
enable_tf32, training, expected, monkeypatch
337+
) -> None:
338+
"""The context selects pt's precision for the mode and restores the old one.
339+
340+
``torch.set_float32_matmul_precision`` is a process global, so a forward
341+
that leaked its setting would silently change every later matmul in the
342+
process; the restore is as much of the contract as the selection.
343+
"""
344+
if not torch.cuda.is_available():
345+
pytest.skip("tf32_precision_ctx is a no-op without CUDA")
346+
monkeypatch.delenv("DP_TF32_INFER", raising=False)
347+
model = get_model(_make_raw_model_config(enable_tf32=enable_tf32))
348+
model.train(training)
349+
350+
torch.set_float32_matmul_precision("highest")
305351
try:
306-
gm_mod.get_sezm_model(_make_raw_model_config(enable_tf32=enable_tf32))
307-
matches = [r for r in records if "enable_tf32" in r.getMessage()]
308-
if enable_tf32:
309-
assert len(matches) == 1, [r.getMessage() for r in records]
310-
# a second call must NOT warn again (warn-once per process)
311-
records.clear()
312-
gm_mod.get_sezm_model(_make_raw_model_config(enable_tf32=enable_tf32))
313-
assert not [r for r in records if "enable_tf32" in r.getMessage()]
314-
else:
315-
assert not matches, [r.getMessage() for r in records]
352+
with model.tf32_precision_ctx():
353+
assert torch.get_float32_matmul_precision() == expected
354+
assert torch.get_float32_matmul_precision() == "highest"
316355
finally:
317-
gm_mod.log.removeHandler(handler)
318-
gm_mod.log.setLevel(old_level)
356+
torch.set_float32_matmul_precision("highest")
357+
358+
359+
def test_non_sezm_model_keeps_full_precision() -> None:
360+
"""The knob is DPA4/SeZM-scoped: other pt_expt models are untouched.
361+
362+
pt declares ``enable_tf32`` inside the dpa4 model arg block and wires it
363+
only in its sezm builders, so a plain se_e2_a model must keep the class
364+
defaults -- full fp32 in both train and eval.
365+
"""
366+
model = get_model(
367+
{
368+
"type_map": ["O", "H"],
369+
"descriptor": {
370+
"type": "se_e2_a",
371+
"sel": [4, 4],
372+
"rcut": 4.0,
373+
"rcut_smth": 3.5,
374+
"seed": 1,
375+
},
376+
"fitting_net": {"seed": 1},
377+
}
378+
)
379+
assert model.enable_tf32 is False
380+
assert model.tf32_infer_precision == "highest"
319381

320382

321383
class TestNativeSpinErrorTranslation(unittest.TestCase):

0 commit comments

Comments
 (0)