Skip to content

Commit 4b6506d

Browse files
wanghan-iapcmOutisLiHan Wang
authored
feat(dpa4): so3_readout across pt + dpmodel/pt_expt backends (#5561)
## What Adds the DPA4/SeZM `so3_readout` option (`"none"` / `"glu"` / `"mlp"`) **across all backends** (pt + dpmodel + pt_expt), making it cross-backend consistent. Builds on **#5556** (@OutisLi): its pt `so3_readout` commit (`refactor(dpa4): output ffn`) is included here with original authorship preserved; this PR adds the missing **dpmodel** counterpart so the shared DPA4 serialize format round-trips across backends. (The unrelated `nv_nlist`/compiler-check fixes from #5556 are intentionally left to #5556.) ## Why `so3_readout` is implemented by configuring the final output FFN — `"glu"`/`"mlp"` turn on the SO(3)-grid FFN (`ffn_so3_grid`, `grid_mlp`). On its own (pt-only, as in #5556) it breaks DPA4 cross-backend consistency: pt `serialize()` emits `so3_readout` but dpmodel `DescrptDPA4` couldn't round-trip it → `source/tests/consistent/descriptor/test_dpa4.py::...::test_pt_consistent_with_ref` failed on every Test Python shard. This is now feasible and small because **#5555** already ported `ffn_so3_grid` + the SO(3)-grid machinery (`SO3GridNet`/`GridMLP`/`GridProduct`) into the dpmodel `EquivariantFFN`. So the dpmodel `so3_readout` is just: accept the param, configure `output_ffn` exactly like pt, wire the readout (l=0 slice for `"none"`; full `(N,D,1,C)` fold for `"glu"`/`"mlp"` then slice l=0), and serialize the key. pt_expt auto-wraps. ## Changes - **pt** (from #5556): `so3_readout` in `DescrptSeZM` + argcheck + `examples/water/dpa4/input.json`. - **dpmodel** `descriptor/dpa4.py`: `so3_readout` param + validation; `output_ffn` configured (`lmax=node_l_schedule[-1]`, `kmax=min(kmax, readout_lmax)`, `ffn_so3_grid`, `grid_mlp`, `grid_branch=0`); readout forward mirrors pt; serialize the key. - **pt_expt**: auto-wrapped (no explicit change). ## Validation - `test_dpa4.py` cross-backend consistency rows for `so3_readout ∈ {none, glu, mlp}` (pt vs dpmodel vs pt_expt, mixed_types) — green; `test_pt_consistent_with_ref` now passes. - Full-descriptor pt→dpmodel parity (weight-copied, `glu`+`mlp`) — **~7e-15 abs** (gate 1e-10), proving serialize interop. - dpa4 suite: 611 passed; ruff clean. ## Notes - Depends on #5555 (merged) for the dpmodel SO(3)-grid FFN. - `so3_readout` no longer "(Supported Backend: PyTorch)" — now multi-backend. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added a configurable `so3_readout` option to the DPA4 and SeZM descriptors (modes: `"none"`, `"glu"`, `"mlp"`), controlling how the final SO(3) readout is computed. * The setting is included in descriptor configuration serialization/deserialization to support round-tripping. * Updated the water DPA4 example to use `so3_readout: "mlp"`. * **Tests** * Added tests covering multiple readout modes, backend parity between implementations, and correct behavior for edge-free scenarios. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: OutisLi <LTC201806070316@gmail.com> Co-authored-by: Han Wang <wang_han@iapcm.ac.cn>
1 parent b1cd6dc commit 4b6506d

7 files changed

Lines changed: 190 additions & 16 deletions

File tree

deepmd/dpmodel/descriptor/dpa4.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ def __init__(
194194
node_wise_so3: bool = False,
195195
message_node_s2: bool = False,
196196
message_node_so3: bool = False,
197+
so3_readout: str = "none",
197198
lebedev_quadrature: bool | list[bool] | None = True,
198199
activation_function: str = "silu",
199200
glu_activation: bool = True,
@@ -275,6 +276,9 @@ def __init__(
275276
self.node_wise_so3 = bool(node_wise_so3)
276277
self.message_node_s2 = bool(message_node_s2)
277278
self.message_node_so3 = bool(message_node_so3)
279+
self.so3_readout = str(so3_readout).lower()
280+
if self.so3_readout not in {"none", "glu", "mlp"}:
281+
raise ValueError("`so3_readout` must be one of 'none', 'glu', or 'mlp'")
278282
if lebedev_quadrature is None:
279283
lebedev_quadrature = [True, True]
280284
elif isinstance(lebedev_quadrature, bool):
@@ -639,12 +643,20 @@ def __init__(
639643
self.blocks = blocks
640644

641645
# === Final FFN for l=0 output mixing (fp32+) ===
646+
# ``so3_readout="none"`` runs a degree-0 scalar FFN on the l=0 slice.
647+
# ``"glu"``/``"mlp"`` run a full FFN at the last block's node degree whose
648+
# SO(3) Wigner-D grid folds l>0 geometry into l=0; the value selects the
649+
# quadratic grid product or the point-wise grid MLP.
650+
readout_lmax = self.node_l_schedule[-1]
642651
self.output_ffn = EquivariantFFN(
643-
lmax=0,
652+
lmax=0 if self.so3_readout == "none" else readout_lmax,
644653
channels=self.channels,
645654
hidden_channels=self.out_ffn_neurons,
646-
grid_mlp=False,
655+
kmax=min(self.kmax, readout_lmax),
656+
grid_mlp=self.so3_readout == "mlp",
657+
grid_branch=0,
647658
s2_activation=False,
659+
ffn_so3_grid=self.so3_readout != "none",
648660
activation_function=self.out_activation_function,
649661
glu_activation=self.out_glu_activation,
650662
mlp_bias=self.mlp_bias,
@@ -926,10 +938,24 @@ def call(
926938
x = block(x, edge_cache, rad_feat_per_block[i])[0]
927939

928940
# === Step 10. Final l=0 output mixing ===
929-
x_scalar = xp.reshape(
930-
x[:, 0:1, :, :], (n_nodes, 1, 1, self.channels)
931-
) # (N, 1, 1, C)
932-
x_scalar = x_scalar + self.output_ffn(x_scalar)
941+
# ``none`` feeds the l=0 slice only; ``glu``/``mlp`` feed the full
942+
# (N, D, 1, C) node tensor so the SO(3) grid folds l>0 into l=0. The
943+
# residual is added on the full coefficient tensor before extracting
944+
# l=0 to mirror pt.
945+
compute_prec = get_xp_precision(xp, self.compute_precision)
946+
if self.so3_readout == "none":
947+
ffn_in = xp.astype(
948+
xp.reshape(x[:, 0:1, :, :], (n_nodes, 1, 1, self.channels)),
949+
compute_prec,
950+
) # (N, 1, 1, C)
951+
else:
952+
# truncate to the final node degree (what output_ffn is built for);
953+
# no-op in the normal path (blocks already shrank x), defensive vs
954+
# any path that leaves x at the initial degree. Mirrors pt.
955+
ffn_in = xp.astype(
956+
x[:, : self.node_ebed_dims[-1], :, :], compute_prec
957+
) # (N, D, 1, C)
958+
x_scalar = (ffn_in + self.output_ffn(ffn_in))[:, 0:1, :, :]
933959

934960
# === Step 11. Reshape and return ===
935961
descriptor = xp.reshape(x_scalar, (nf, nloc, self.channels))
@@ -1231,6 +1257,7 @@ def serialize(self) -> dict[str, Any]:
12311257
"node_wise_so3": self.node_wise_so3,
12321258
"message_node_s2": self.message_node_s2,
12331259
"message_node_so3": self.message_node_so3,
1260+
"so3_readout": self.so3_readout,
12341261
"lebedev_quadrature": self.lebedev_quadrature,
12351262
"activation_function": self.activation_function,
12361263
"glu_activation": self.glu_activation,

deepmd/pt/model/descriptor/sezm.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,16 @@ class DescrptSeZM(BaseDescriptor, nn.Module):
320320
message_node_so3
321321
If True, use the corresponding post-aggregation SO(3) Wigner-D grid-net
322322
branch. The message is the query and the node state is the context.
323+
so3_readout
324+
Read-out FFN mode for the final ``l=0`` descriptor. ``"none"`` applies a
325+
degree-0 scalar FFN to the ``l=0`` slice only; ``l>0`` coefficients are
326+
discarded before the read-out. ``"glu"`` and ``"mlp"`` apply a full
327+
equivariant FFN whose degree equals the node degree of the last
328+
interaction block, driven by the SO(3) Wigner-D grid, so ``l>0`` geometry
329+
is folded into ``l=0`` before the scalar is extracted. The value selects
330+
the quadratic grid product (``"glu"``) or the polynomial point-wise grid
331+
MLP (``"mlp"``). The Wigner-D frame order follows ``kmax``. The residual
332+
stays on the ``l=0`` channel.
323333
lebedev_quadrature
324334
Either one boolean applied to both S2 branches, or two booleans
325335
``[so2_enabled, ffn_enabled]`` aligned with ``s2_activation``. If
@@ -425,6 +435,7 @@ def __init__(
425435
node_wise_so3: bool = False,
426436
message_node_s2: bool = False,
427437
message_node_so3: bool = False,
438+
so3_readout: str = "none",
428439
lebedev_quadrature: bool | list[bool] | None = True,
429440
activation_function: str = "silu",
430441
glu_activation: bool = True,
@@ -512,6 +523,9 @@ def __init__(
512523
self.node_wise_so3 = bool(node_wise_so3)
513524
self.message_node_s2 = bool(message_node_s2)
514525
self.message_node_so3 = bool(message_node_so3)
526+
self.so3_readout = str(so3_readout).lower()
527+
if self.so3_readout not in {"none", "glu", "mlp"}:
528+
raise ValueError("`so3_readout` must be one of 'none', 'glu', or 'mlp'")
515529
if lebedev_quadrature is None:
516530
lebedev_quadrature = [True, True]
517531
elif isinstance(lebedev_quadrature, bool):
@@ -932,13 +946,21 @@ def __init__(
932946
)
933947

934948
# === Final FFN for l=0 output mixing ===
949+
# ``so3_readout="none"`` runs a degree-0 scalar FFN on the l=0 slice.
950+
# ``"glu"``/``"mlp"`` run a full FFN at the last block's node degree whose
951+
# SO(3) Wigner-D grid folds l>0 geometry into l=0; the value selects the
952+
# quadratic grid product or the point-wise grid MLP.
953+
readout_lmax = self.node_l_schedule[-1]
935954
self.output_ffn = EquivariantFFN(
936-
lmax=0,
955+
lmax=0 if self.so3_readout == "none" else readout_lmax,
937956
channels=self.channels,
938957
hidden_channels=self.out_ffn_neurons,
939-
grid_mlp=False,
958+
kmax=min(self.kmax, readout_lmax),
959+
grid_mlp=self.so3_readout == "mlp",
960+
grid_branch=0,
940961
dtype=self.compute_dtype,
941962
s2_activation=False,
963+
ffn_so3_grid=self.so3_readout != "none",
942964
activation_function=self.out_activation_function,
943965
glu_activation=self.out_glu_activation,
944966
mlp_bias=self.mlp_bias,
@@ -1205,15 +1227,23 @@ def forward(
12051227
x = self._forward_blocks(x, edge_cache, rad_feat_per_block)
12061228

12071229
# === Step 11. Final l=0 output mixing ===
1208-
# Extract l=0 scalar features and apply FFN in promoted dtype.
1209-
# Residual keeps the output close to identity with zero-initialized FFN output.
1230+
# ``none`` feeds the l=0 slice only; ``glu``/``mlp`` feed the full
1231+
# (N, D, 1, C) node tensor so the SO(3) grid folds l>0 into l=0. The
1232+
# residual is added on the full coefficient tensor before extracting
1233+
# l=0: slicing the summed tensor rather than the FFN output keeps the
1234+
# saved degree-axis stride static under torch.compile dynamic shapes.
12101235
with nvtx_range("output_ffn"):
1211-
x_scalar = (
1236+
ffn_in = (
12121237
x[:, 0:1, :, :]
12131238
.reshape(n_nodes, 1, 1, self.channels)
12141239
.to(dtype=self.compute_dtype)
1215-
) # (N, 1, 1, C)
1216-
x_scalar = x_scalar + self.output_ffn(x_scalar)
1240+
if self.so3_readout == "none"
1241+
# truncate to the final node degree: the empty-edge path
1242+
# skips the blocks, leaving x at node_ebed_dims[0]; output_ffn
1243+
# is built for node_ebed_dims[-1]. No-op when blocks ran.
1244+
else x[:, : self.node_ebed_dims[-1], :, :].to(dtype=self.compute_dtype)
1245+
)
1246+
x_scalar = (ffn_in + self.output_ffn(ffn_in))[:, 0:1, :, :]
12171247

12181248
# === Step 12. Reshape to (nf, nloc, channels) and return ===
12191249
descriptor = rearrange(
@@ -1380,13 +1410,23 @@ def forward_with_edges(
13801410
x = self._forward_blocks(x, edge_cache, rad_feat_per_block)
13811411

13821412
# === Step 10. Final l=0 output mixing ===
1413+
# ``none`` feeds the l=0 slice only; ``glu``/``mlp`` feed the full
1414+
# (N, D, 1, C) node tensor so the SO(3) grid folds l>0 into l=0. The
1415+
# residual is added on the full coefficient tensor before extracting
1416+
# l=0: slicing the summed tensor rather than the FFN output keeps the
1417+
# saved degree-axis stride static under torch.compile dynamic shapes.
13831418
with nvtx_range("output_ffn"):
1384-
x_scalar = (
1419+
ffn_in = (
13851420
x[:, 0:1, :, :]
13861421
.reshape(n_nodes, 1, 1, self.channels)
13871422
.to(dtype=self.compute_dtype)
1388-
) # (N, 1, 1, C)
1389-
x_scalar = x_scalar + self.output_ffn(x_scalar)
1423+
if self.so3_readout == "none"
1424+
# truncate to the final node degree: the empty-edge path
1425+
# skips the blocks, leaving x at node_ebed_dims[0]; output_ffn
1426+
# is built for node_ebed_dims[-1]. No-op when blocks ran.
1427+
else x[:, : self.node_ebed_dims[-1], :, :].to(dtype=self.compute_dtype)
1428+
)
1429+
x_scalar = (ffn_in + self.output_ffn(ffn_in))[:, 0:1, :, :]
13901430

13911431
# === Step 11. Reshape to (nf, nloc, channels) and return ===
13921432
descriptor = x_scalar.reshape(nf, nloc, self.channels) # (nf, nloc, C)
@@ -2043,6 +2083,7 @@ def serialize(self) -> dict[str, Any]:
20432083
"node_wise_so3": self.node_wise_so3,
20442084
"message_node_s2": self.message_node_s2,
20452085
"message_node_so3": self.message_node_so3,
2086+
"so3_readout": self.so3_readout,
20462087
"lebedev_quadrature": self.lebedev_quadrature,
20472088
"activation_function": self.activation_function,
20482089
"glu_activation": self.glu_activation,

deepmd/utils/argcheck.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,16 @@ def descrpt_se_zm_args() -> list[Argument]:
551551
"context. When enabled together with `message_node_s2`, the SO(3) "
552552
"branch is used for this path."
553553
)
554+
doc_so3_readout = (
555+
"Read-out FFN mode for the final l=0 descriptor. `none` applies a "
556+
"degree-0 scalar FFN to the l=0 slice only; l>0 coefficients are "
557+
"discarded before the read-out. `glu` and `mlp` apply a full equivariant "
558+
"FFN on the SO(3) Wigner-D grid so l>0 geometry is folded into l=0 "
559+
"before the scalar is extracted; the value selects the quadratic grid "
560+
"product (`glu`) or the polynomial point-wise grid MLP (`mlp`). The "
561+
"read-out degree equals the node degree of the last interaction block; "
562+
"the Wigner-D frame order follows `kmax`."
563+
)
554564
doc_lebedev_quadrature = (
555565
"Either one boolean applied to both S2 branches, or two booleans "
556566
"`[so2_enabled, ffn_enabled]` aligned with `s2_activation`. If a branch "
@@ -881,6 +891,15 @@ def descrpt_se_zm_args() -> list[Argument]:
881891
default=False,
882892
doc=doc_only_pt_supported + doc_message_node_so3,
883893
),
894+
Argument(
895+
"so3_readout",
896+
str,
897+
optional=True,
898+
default="none",
899+
extra_check=lambda x: x in ("none", "glu", "mlp"),
900+
extra_check_errmsg="must be one of 'none', 'glu', or 'mlp'",
901+
doc=doc_only_pt_supported + doc_so3_readout,
902+
),
884903
Argument(
885904
"lebedev_quadrature",
886905
[bool, list[bool]],

examples/water/dpa4/input.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"n_atten_head": 1,
2424
"ffn_neurons": 0,
2525
"ffn_so3_grid": true,
26+
"so3_readout": "mlp",
2627
"grid_mlp": [
2728
false,
2829
false,

source/tests/consistent/descriptor/test_dpa4.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"ffn_so3_grid",
5050
"message_node_so3",
5151
"grid_mlp",
52+
"so3_readout",
5253
)
5354

5455
DPA4_BASELINE_CASE = {
@@ -59,6 +60,7 @@
5960
"ffn_so3_grid": False,
6061
"message_node_so3": False,
6162
"grid_mlp": False,
63+
"so3_readout": "none",
6264
}
6365

6466

@@ -99,6 +101,10 @@ def dpa4_case(**overrides: Any) -> tuple:
99101
dpa4_case(ffn_so3_grid=True, message_node_so3=True),
100102
# polynomial grid MLP op (grid_branch=0 so grid_mlp takes effect)
101103
dpa4_case(grid_mlp=True, grid_branch=[0, 0, 0]),
104+
# SO(3) readout: GLU grid product folds l>0 into the l=0 output
105+
dpa4_case(so3_readout="glu"),
106+
# SO(3) readout: point-wise grid MLP folds l>0 into the l=0 output
107+
dpa4_case(so3_readout="mlp"),
102108
)
103109

104110

@@ -114,6 +120,7 @@ def data(self) -> dict:
114120
ffn_so3_grid,
115121
message_node_so3,
116122
grid_mlp,
123+
so3_readout,
117124
) = self.param
118125
return {
119126
"ntypes": self.ntypes,
@@ -130,6 +137,7 @@ def data(self) -> dict:
130137
"ffn_so3_grid": ffn_so3_grid,
131138
"message_node_so3": message_node_so3,
132139
"grid_mlp": grid_mlp,
140+
"so3_readout": so3_readout,
133141
"random_gamma": False,
134142
"precision": precision,
135143
"trainable": False,

source/tests/pt/model/test_descriptor_sezm.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,30 @@ def _assert_forward_backward_smoke(self, **model_kwargs) -> DescrptSeZM:
155155
self.assertTrue(torch.all(torch.isfinite(extended_coord.grad)))
156156
return model
157157

158+
def test_so3_readout_empty_edge_shrinking_schedule(self) -> None:
159+
"""so3_readout glu/mlp must handle the empty-edge path.
160+
161+
With a shrinking ``l_schedule`` and no edges (every atom isolated),
162+
``_forward_blocks`` is skipped so ``x`` keeps the *initial* node degree
163+
``node_ebed_dims[0]``; the readout must truncate it to the final degree
164+
``node_ebed_dims[-1]`` (what ``output_ffn`` is built for) before the FFN.
165+
Regression for the readout shape mismatch on isolated atoms.
166+
"""
167+
coord, atype, _ = _tiny_two_atom_system(self.device, dtype=torch.float32)
168+
extended_coord = coord.reshape(1, -1).detach().requires_grad_(True)
169+
# all neighbors masked out -> edge_cache.src.numel() == 0 -> blocks skipped
170+
nlist = torch.full((1, 2, 2), -1, dtype=torch.int64, device=self.device)
171+
for readout in ("glu", "mlp"):
172+
with self.subTest(so3_readout=readout):
173+
model = DescrptSeZM(
174+
**_descriptor_kwargs(l_schedule=[2, 1], so3_readout=readout)
175+
)
176+
desc, *_ = model(
177+
extended_coord, atype, nlist, mapping=None, comm_dict=None
178+
)
179+
self.assertEqual(desc.shape, (1, 2, 4))
180+
self.assertTrue(torch.all(torch.isfinite(desc)))
181+
158182
def test_forward_with_descriptor_variants(self) -> None:
159183
"""Test forward/backward smoke paths for compact descriptor variants."""
160184
cases = {

source/tests/pt/model/test_dpa4_dpmodel_parity.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3578,6 +3578,60 @@ def test_descriptor_extra_node_l(self) -> None:
35783578
pt_mod, dp_mod, _ = self._build_descr_pair(extra_node_l=1)
35793579
self._assert_descr_parity(pt_mod, dp_mod)
35803580

3581+
@pytest.mark.parametrize(
3582+
"so3_readout", ["glu", "mlp"]
3583+
) # SO(3) grid readout: quadratic grid product vs point-wise grid MLP
3584+
def test_descriptor_so3_readout(self, so3_readout) -> None:
3585+
# so3_readout!="none" feeds the full (N, D, 1, C) node tensor to the
3586+
# output FFN so the SO(3) Wigner-D grid folds l>0 into l=0. The pt
3587+
# reference is pinned to CPU so the parity holds under the CUDA default
3588+
# device; the gate stays at the strict fp64 descriptor tolerance.
3589+
from deepmd.dpmodel.descriptor.dpa4 import (
3590+
DescrptDPA4,
3591+
)
3592+
from deepmd.pt.model.descriptor.sezm import (
3593+
DescrptSeZM,
3594+
)
3595+
3596+
kwargs = self._descr_kwargs(so3_readout=so3_readout)
3597+
pt_mod = DescrptSeZM(**kwargs).double().eval().to("cpu")
3598+
# so3_linear_2 / output projections are zero-initialized; perturb so the
3599+
# readout output is nontrivial (otherwise it is identically ~0)
3600+
rng = np.random.default_rng(2160)
3601+
with torch.no_grad():
3602+
for p in pt_mod.parameters():
3603+
p += torch.from_numpy(0.05 * rng.normal(size=tuple(p.shape))).to("cpu")
3604+
dp_mod = DescrptDPA4.deserialize(pt_mod.serialize())
3605+
assert dp_mod.so3_readout == so3_readout
3606+
3607+
inp = self._inputs()
3608+
coord, atype_ext, nlist, mp = (
3609+
inp["coord"],
3610+
inp["atype_ext"],
3611+
inp["nlist"],
3612+
inp["mapping"],
3613+
)
3614+
nf = coord.shape[0]
3615+
out_dp = np.asarray(
3616+
dp_mod.call(coord.reshape(nf, -1), atype_ext, nlist, mapping=mp)[0]
3617+
)
3618+
out_pt = (
3619+
pt_mod(
3620+
torch.from_numpy(coord).to("cpu"),
3621+
torch.from_numpy(atype_ext.astype(np.int64)).to("cpu"),
3622+
torch.from_numpy(nlist.astype(np.int64)).to("cpu"),
3623+
mapping=torch.from_numpy(mp.astype(np.int64)).to("cpu"),
3624+
)[0]
3625+
.detach()
3626+
.cpu()
3627+
.numpy()
3628+
)
3629+
assert out_dp.shape == out_pt.shape
3630+
# nontrivial output magnitude (guards against a trivially-zero readout)
3631+
assert np.abs(out_dp).max() > 1e-6
3632+
# strict fp64 descriptor-level gate
3633+
np.testing.assert_allclose(out_dp, out_pt, rtol=1e-10, atol=1e-12)
3634+
35813635
def test_descriptor_torch_namespace(self) -> None:
35823636
# the dp descriptor must run under the torch array namespace as well:
35833637
# feeding torch tensors must yield a torch tensor matching the numpy

0 commit comments

Comments
 (0)