Skip to content

Commit 6122d97

Browse files
wanghan-iapcmHan Wang
andauthored
feat(pt_expt): add dp compress support for pt_expt backend (#5323)
## Summary - Add model compression (embedding net tabulation) for the pt_expt backend, matching the existing pt backend capability - Compressed models replace embedding net forward passes with polynomial lookup tables via C++ custom ops (`tabulate_fusion_se_*`), significantly speeding up inference - Support all compressible descriptors: `se_e2_a`, `se_r`, `se_t`, `se_t_tebd`, `dpa1`, `se_atten_v2`, `dpa2` (hybrid delegates automatically) ### Key changes **Infrastructure:** - `deepmd/pt_expt/utils/tabulate_ops.py` — Register `torch.library.register_fake` for all 5 custom ops to enable `torch.export`/`make_fx` tracing through compressed forward paths - `deepmd/pt_expt/utils/tabulate.py` — `DPTabulate` subclass that detects descriptor type via serialized data (avoids `isinstance` checks against pt-specific classes) - `deepmd/pt_expt/entrypoints/compress.py` — Entry point: load `.pte` → deserialize → `enable_compression()` → re-export `.pte` **Descriptors:** Each gets `enable_compression()` + `@cast_precision` `call()` override with compressed branch using the appropriate custom op. **dpmodel — compression state serialization (breaking version bumps):** The pt_expt backend persists models via `serialize()` → `model.json` → `deserialize()` (the `.pte` format), unlike pt/tf which use native framework save mechanisms (torch.jit.save / tf.saved_model) that capture the full runtime state. This means compression state (tabulated polynomial coefficients, precomputed type embeddings) must survive the serialize/deserialize round-trip for compressed `.pte` models to work. Each compressible descriptor's serialization version is bumped when the model is compressed. **Uncompressed models continue to use the old version**, so there is no breakage for existing uncompressed model files. All backends (pt, pd, tf) accept the new version in `deserialize()` and simply ignore the `"compress"` key. | Descriptor | Version bump | Added fields | |---|---|---| | `se_e2_a` | 2 → 3 | `compress_data`, `compress_info` | | `se_r` | 2 → 3 | `compress_data`, `compress_info` | | `se_t` | 2 → 3 | `compress_data`, `compress_info` | | `se_t_tebd` | 1 → 2 | `compress_data`, `compress_info`, `type_embd_data` | | `dpa1` | 2 → 3 | `type_embd_data`, `geo_compress`, `compress_data`/`info` (if geo) | | `se_atten_v2` | 2 → 3 | `type_embd_data`, `geo_compress`, `compress_data`/`info` (if geo) | | `dpa2` | 3 → 4 | compress dict inside `repinit_variable` | **dpmodel:** Initialize `self.compress = False` in all descriptor `__init__` methods. ## Test plan - [x] `source/tests/pt_expt/model/test_model_compression.py` — end-to-end compress → serialize → deserialize → eval - [x] `source/tests/pt_expt/descriptor/` — compressed forward, consistency, exportable, make_fx tests for all descriptors - [x] `source/tests/consistent/descriptor/` — cross-backend consistency tests pass with bumped versions <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added descriptor compression functionality to reduce model size and optimize memory usage during inference. * Introduced `compress` CLI command to enable tabulated embedding optimization on frozen trained models. * Enhanced descriptor serialization with improved version compatibility across multiple backends. * **Tests** * Added comprehensive test coverage for compressed descriptor forward passes and model compression workflows. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Han Wang <wang_han@iapcm.ac.cn>
1 parent b97ad98 commit 6122d97

46 files changed

Lines changed: 2706 additions & 35 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

deepmd/dpmodel/descriptor/dpa1.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ def __init__(
344344
self.concat_output_tebd = concat_output_tebd
345345
self.trainable = trainable
346346
self.precision = precision
347+
self.compress = False
347348

348349
def get_rcut(self) -> float:
349350
"""Returns the cut-off radius."""
@@ -557,7 +558,7 @@ def serialize(self) -> dict:
557558
data = {
558559
"@class": "Descriptor",
559560
"type": "dpa1",
560-
"@version": 2,
561+
"@version": 3 if self.compress else 2,
561562
"rcut": obj.rcut,
562563
"rcut_smth": obj.rcut_smth,
563564
"sel": obj.sel,
@@ -602,20 +603,36 @@ def serialize(self) -> dict:
602603
}
603604
if obj.tebd_input_mode in ["strip"]:
604605
data.update({"embeddings_strip": obj.embeddings_strip.serialize()})
606+
if self.compress:
607+
compress_dict: dict = {
608+
"@variables": {
609+
"type_embd_data": to_numpy_array(self.type_embd_data),
610+
},
611+
"geo_compress": self.geo_compress,
612+
}
613+
if self.geo_compress:
614+
compress_dict["@variables"]["compress_data"] = [
615+
to_numpy_array(d) for d in self.compress_data
616+
]
617+
compress_dict["@variables"]["compress_info"] = [
618+
to_numpy_array(i) for i in self.compress_info
619+
]
620+
data["compress"] = compress_dict
605621
return data
606622

607623
@classmethod
608624
def deserialize(cls, data: dict) -> "DescrptDPA1":
609625
"""Deserialize from dict."""
610626
data = data.copy()
611-
check_version_compatibility(data.pop("@version"), 2, 1)
627+
check_version_compatibility(data.pop("@version"), 3, 1)
612628
data.pop("@class")
613629
data.pop("type")
614630
variables = data.pop("@variables")
615631
embeddings = data.pop("embeddings")
616632
type_embedding = data.pop("type_embedding")
617633
attention_layers = data.pop("attention_layers")
618634
env_mat = data.pop("env_mat")
635+
compress = data.pop("compress", None)
619636
tebd_input_mode = data["tebd_input_mode"]
620637
if tebd_input_mode in ["strip"]:
621638
embeddings_strip = data.pop("embeddings_strip")
@@ -637,8 +654,20 @@ def deserialize(cls, data: dict) -> "DescrptDPA1":
637654
obj.se_atten.dpa1_attention = NeighborGatedAttention.deserialize(
638655
attention_layers
639656
)
657+
if compress is not None:
658+
obj._load_compress_data(compress)
640659
return obj
641660

661+
def _load_compress_data(self, compress: dict) -> None:
662+
"""Load compression state from serialized data."""
663+
variables = compress["@variables"]
664+
self.type_embd_data = variables["type_embd_data"]
665+
self.geo_compress = compress.get("geo_compress", False)
666+
if self.geo_compress:
667+
self.compress_data = variables["compress_data"]
668+
self.compress_info = variables["compress_info"]
669+
self.compress = True
670+
642671
@classmethod
643672
def update_sel(
644673
cls,

deepmd/dpmodel/descriptor/dpa2.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ def init_subclass_params(sub_data: dict | Any, sub_class: type) -> Any:
596596
self.rcut_smth = self.repinit.get_rcut_smth()
597597
self.trainable = trainable
598598
self.add_tebd_to_repinit_out = add_tebd_to_repinit_out
599+
self.compress = False
599600

600601
self.repinit_out_dim = self.repinit.dim_out
601602
if self.repinit_args.use_three_body:
@@ -938,7 +939,7 @@ def serialize(self) -> dict:
938939
data = {
939940
"@class": "Descriptor",
940941
"type": "dpa2",
941-
"@version": 3,
942+
"@version": 4 if self.compress else 3,
942943
"ntypes": self.ntypes,
943944
"repinit_args": self.repinit_args.serialize(),
944945
"repformer_args": self.repformer_args.serialize(),
@@ -973,6 +974,21 @@ def serialize(self) -> dict:
973974
repinit_variable.update(
974975
{"embeddings_strip": repinit.embeddings_strip.serialize()}
975976
)
977+
if self.compress:
978+
compress_dict: dict = {
979+
"@variables": {
980+
"type_embd_data": to_numpy_array(self.type_embd_data),
981+
},
982+
"geo_compress": self.geo_compress,
983+
}
984+
if self.geo_compress:
985+
compress_dict["@variables"]["compress_data"] = [
986+
to_numpy_array(d) for d in self.compress_data
987+
]
988+
compress_dict["@variables"]["compress_info"] = [
989+
to_numpy_array(i) for i in self.compress_info
990+
]
991+
repinit_variable["compress"] = compress_dict
976992
repformers_variable = {
977993
"g2_embd": repformers.g2_embd.serialize(),
978994
"repformer_layers": [layer.serialize() for layer in repformers.layers],
@@ -1016,7 +1032,7 @@ def serialize(self) -> dict:
10161032
def deserialize(cls, data: dict) -> "DescrptDPA2":
10171033
data = data.copy()
10181034
version = data.pop("@version")
1019-
check_version_compatibility(version, 3, 1)
1035+
check_version_compatibility(version, 4, 1)
10201036
data.pop("@class")
10211037
data.pop("type")
10221038
repinit_variable = data.pop("repinit_variable").copy()
@@ -1040,6 +1056,7 @@ def deserialize(cls, data: dict) -> "DescrptDPA2":
10401056
# compat with version 1
10411057
if "use_tebd_bias" not in data:
10421058
data["use_tebd_bias"] = True
1059+
compress = repinit_variable.pop("compress", None)
10431060
obj = cls(**data)
10441061
obj.type_embedding = TypeEmbedNet.deserialize(type_embedding)
10451062
if add_tebd_to_repinit_out:
@@ -1089,8 +1106,20 @@ def deserialize(cls, data: dict) -> "DescrptDPA2":
10891106
obj.repformers.layers = [
10901107
RepformerLayer.deserialize(layer) for layer in repformer_layers
10911108
]
1109+
if compress is not None:
1110+
obj._load_compress_data(compress)
10921111
return obj
10931112

1113+
def _load_compress_data(self, compress: dict) -> None:
1114+
"""Load compression state from serialized data."""
1115+
variables = compress["@variables"]
1116+
self.type_embd_data = variables["type_embd_data"]
1117+
self.geo_compress = compress.get("geo_compress", False)
1118+
if self.geo_compress:
1119+
self.compress_data = variables["compress_data"]
1120+
self.compress_info = variables["compress_info"]
1121+
self.compress = True
1122+
10941123
@classmethod
10951124
def update_sel(
10961125
cls,

deepmd/dpmodel/descriptor/se_atten_v2.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,14 +196,15 @@ def __init__(
196196
# consistent with argcheck, not used though
197197
seed=seed,
198198
)
199+
self.compress = False
199200

200201
def serialize(self) -> dict:
201202
"""Serialize the descriptor to dict."""
202203
obj = self.se_atten
203204
data = {
204205
"@class": "Descriptor",
205206
"type": "se_atten_v2",
206-
"@version": 2,
207+
"@version": 3 if self.compress else 2,
207208
"rcut": obj.rcut,
208209
"rcut_smth": obj.rcut_smth,
209210
"sel": obj.sel,
@@ -245,13 +246,28 @@ def serialize(self) -> dict:
245246
"trainable": self.trainable,
246247
"spin": None,
247248
}
249+
if self.compress:
250+
compress_dict: dict = {
251+
"@variables": {
252+
"type_embd_data": to_numpy_array(self.type_embd_data),
253+
},
254+
"geo_compress": self.geo_compress,
255+
}
256+
if self.geo_compress:
257+
compress_dict["@variables"]["compress_data"] = [
258+
to_numpy_array(d) for d in self.compress_data
259+
]
260+
compress_dict["@variables"]["compress_info"] = [
261+
to_numpy_array(i) for i in self.compress_info
262+
]
263+
data["compress"] = compress_dict
248264
return data
249265

250266
@classmethod
251267
def deserialize(cls, data: dict) -> "DescrptSeAttenV2":
252268
"""Deserialize from dict."""
253269
data = data.copy()
254-
check_version_compatibility(data.pop("@version"), 2, 1)
270+
check_version_compatibility(data.pop("@version"), 3, 1)
255271
data.pop("@class")
256272
data.pop("type")
257273
variables = data.pop("@variables")
@@ -260,6 +276,7 @@ def deserialize(cls, data: dict) -> "DescrptSeAttenV2":
260276
attention_layers = data.pop("attention_layers")
261277
data.pop("env_mat")
262278
embeddings_strip = data.pop("embeddings_strip")
279+
compress = data.pop("compress", None)
263280
# compat with version 1
264281
if "use_tebd_bias" not in data:
265282
data["use_tebd_bias"] = True
@@ -273,4 +290,16 @@ def deserialize(cls, data: dict) -> "DescrptSeAttenV2":
273290
obj.se_atten.dpa1_attention = NeighborGatedAttention.deserialize(
274291
attention_layers
275292
)
293+
if compress is not None:
294+
obj._load_compress_data(compress)
276295
return obj
296+
297+
def _load_compress_data(self, compress: dict) -> None:
298+
"""Load compression state from serialized data."""
299+
variables = compress["@variables"]
300+
self.type_embd_data = variables["type_embd_data"]
301+
self.geo_compress = compress.get("geo_compress", False)
302+
if self.geo_compress:
303+
self.compress_data = variables["compress_data"]
304+
self.compress_info = variables["compress_info"]
305+
self.compress = True

deepmd/dpmodel/descriptor/se_e2_a.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def __init__(
192192
self.precision = precision
193193
self.spin = spin
194194
self.type_map = type_map
195+
self.compress = False
195196
# order matters, placed after the assignment of self.ntypes
196197
self.reinit_exclude(exclude_types)
197198

@@ -514,10 +515,24 @@ def serialize(self) -> dict:
514515
if embedding_idx in self.emask:
515516
self.embeddings[embedding_idx].clear()
516517

517-
return {
518+
# Serialization version history:
519+
# v2: original format
520+
# v3: added optional "compress" key for model compression state
521+
# (tabulated embedding-net polynomial coefficients).
522+
# Uncompressed models stay at v2 for full backward compatibility.
523+
#
524+
# Why serialize compression state here:
525+
# The pt and tf backends persist compression via their native
526+
# framework save mechanisms (torch.jit.save / tf.saved_model),
527+
# which capture the full runtime state including tabulated data.
528+
# The pt_expt backend uses serialize() -> model.json -> deserialize()
529+
# as the primary persistence path (.pte files), so compression state
530+
# must survive this round-trip. All backends accept v3 in
531+
# deserialize() to allow cross-backend loading of compressed models.
532+
data = {
518533
"@class": "Descriptor",
519534
"type": "se_e2_a",
520-
"@version": 2,
535+
"@version": 3 if self.compress else 2,
521536
"rcut": self.rcut,
522537
"rcut_smth": self.rcut_smth,
523538
"sel": self.sel,
@@ -541,24 +556,50 @@ def serialize(self) -> dict:
541556
},
542557
"type_map": self.type_map,
543558
}
559+
if self.compress:
560+
data["compress"] = {
561+
"@variables": {
562+
"compress_data": [to_numpy_array(d) for d in self.compress_data],
563+
"compress_info": [to_numpy_array(i) for i in self.compress_info],
564+
},
565+
}
566+
return data
544567

545568
@classmethod
546569
def deserialize(cls, data: dict) -> "DescrptSeA":
547570
"""Deserialize from dict."""
548571
data = data.copy()
549-
check_version_compatibility(data.pop("@version", 1), 2, 1)
572+
check_version_compatibility(data.pop("@version", 1), 3, 1)
550573
data.pop("@class", None)
551574
data.pop("type", None)
552575
variables = data.pop("@variables")
553576
embeddings = data.pop("embeddings")
554577
env_mat = data.pop("env_mat")
578+
compress = data.pop("compress", None)
555579
obj = cls(**data)
556580

557581
obj["davg"] = variables["davg"]
558582
obj["dstd"] = variables["dstd"]
559583
obj.embeddings = NetworkCollection.deserialize(embeddings)
584+
if compress is not None:
585+
obj._load_compress_data(compress)
560586
return obj
561587

588+
def _load_compress_data(self, compress: dict) -> None:
589+
"""Load compression state from serialized data.
590+
591+
Parameters
592+
----------
593+
compress : dict
594+
Must contain "@variables" with "compress_data" (list of arrays,
595+
one per embedding network) and "compress_info" (list of arrays
596+
with table bounds).
597+
"""
598+
variables = compress["@variables"]
599+
self.compress_data = variables["compress_data"]
600+
self.compress_info = variables["compress_info"]
601+
self.compress = True
602+
562603
@classmethod
563604
def update_sel(
564605
cls,

deepmd/dpmodel/descriptor/se_r.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ def __init__(
173173
self.type_map = type_map
174174
self.emask = PairExcludeMask(self.ntypes, self.exclude_types)
175175
self.env_protection = env_protection
176+
self.compress = False
176177

177178
in_dim = 1 # not considiering type embedding
178179
embeddings = NetworkCollection(
@@ -438,10 +439,10 @@ def call(
438439

439440
def serialize(self) -> dict:
440441
"""Serialize the descriptor to dict."""
441-
return {
442+
data = {
442443
"@class": "Descriptor",
443444
"type": "se_r",
444-
"@version": 2,
445+
"@version": 3 if self.compress else 2,
445446
"rcut": self.rcut,
446447
"rcut_smth": self.rcut_smth,
447448
"sel": self.sel,
@@ -464,24 +465,42 @@ def serialize(self) -> dict:
464465
},
465466
"type_map": self.type_map,
466467
}
468+
if self.compress:
469+
data["compress"] = {
470+
"@variables": {
471+
"compress_data": [to_numpy_array(d) for d in self.compress_data],
472+
"compress_info": [to_numpy_array(i) for i in self.compress_info],
473+
},
474+
}
475+
return data
467476

468477
@classmethod
469478
def deserialize(cls, data: dict) -> "DescrptSeR":
470479
"""Deserialize from dict."""
471480
data = data.copy()
472-
check_version_compatibility(data.pop("@version", 1), 2, 1)
481+
check_version_compatibility(data.pop("@version", 1), 3, 1)
473482
data.pop("@class", None)
474483
data.pop("type", None)
475484
variables = data.pop("@variables")
476485
embeddings = data.pop("embeddings")
477486
env_mat = data.pop("env_mat")
487+
compress = data.pop("compress", None)
478488
obj = cls(**data)
479489

480490
obj["davg"] = variables["davg"]
481491
obj["dstd"] = variables["dstd"]
482492
obj.embeddings = NetworkCollection.deserialize(embeddings)
493+
if compress is not None:
494+
obj._load_compress_data(compress)
483495
return obj
484496

497+
def _load_compress_data(self, compress: dict) -> None:
498+
"""Load compression state from serialized data."""
499+
variables = compress["@variables"]
500+
self.compress_data = variables["compress_data"]
501+
self.compress_info = variables["compress_info"]
502+
self.compress = True
503+
485504
@classmethod
486505
def update_sel(
487506
cls,

0 commit comments

Comments
 (0)