Skip to content

Commit 85dbbae

Browse files
gregmacnamarameta-codesync[bot]
authored andcommitted
eval-only DATA_PARALLEL embeddings skip the DDP gradient reducer (meta-pytorch#4499)
Summary: Pull Request resolved: meta-pytorch#4499 Add an opt-out for the per-table DATA_PARALLEL `DistributedDataParallel` gradient reducer on embedding modules. Marking a source module with `mark_data_parallel_skip_grad_sync(module)` makes its DATA_PARALLEL lookups in `ShardedEmbeddingBagCollection` / `ShardedEmbeddingCollection` replicate WITHOUT the DDP wrap -- intended for frozen / forward-only modules that never run backward, where the reducer's grad buckets would only ever hold zeros. The raw DP lookup forwards correctly on its own (DDP is only needed for the backward all-reduce). Default is unchanged: absent the marker the DDP wrap is created exactly as before, so trained modules are byte-identical. Reviewed By: spmex, TroyGarden Differential Revision: D113473115 fbshipit-source-id: 298fa1b3db2722f6194e71bbe9124cd9158ca299
1 parent 57a31fc commit 85dbbae

4 files changed

Lines changed: 324 additions & 18 deletions

File tree

torchrec/distributed/embedding.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
from torch.nn.modules.module import _IncompatibleKeys
3535
from torch.nn.parallel import DistributedDataParallel
3636
from torchrec.distributed.comm import get_local_size
37-
from torchrec.distributed.embedding_lookup import PartiallyMaterializedTensor
37+
from torchrec.distributed.embedding_lookup import (
38+
GroupedEmbeddingsLookup,
39+
PartiallyMaterializedTensor,
40+
)
3841
from torchrec.distributed.embedding_sharding import (
3942
EmbeddingSharding,
4043
EmbeddingShardingInfo,
@@ -103,6 +106,7 @@
103106
from torchrec.modules.embedding_modules import (
104107
EmbeddingCollection,
105108
EmbeddingCollectionInterface,
109+
should_skip_data_parallel_grad_sync,
106110
)
107111
from torchrec.modules.utils import construct_jagged_tensors, SequenceVBEContext
108112
from torchrec.optim.fused import EmptyFusedOptimizer, FusedOptimizerModule
@@ -463,6 +467,10 @@ def __init__(
463467
self._enable_feature_score_weight_accumulation: bool = False
464468

465469
self._module_fqn = module_fqn
470+
# Opt-out of the DATA_PARALLEL DDP gradient reducer (see embedding_modules).
471+
self._skip_data_parallel_grad_sync: bool = should_skip_data_parallel_grad_sync(
472+
module
473+
)
466474
self._embedding_configs: List[EmbeddingConfig] = module.embedding_configs()
467475
self._table_names: List[str] = [
468476
config.name for config in self._embedding_configs
@@ -603,6 +611,9 @@ def init_data_parallel(self) -> None:
603611
"""
604612
Initialize data parallel for the embedding collection.
605613
"""
614+
# Opt-out: skip the DDP wrap; the raw DP lookup forwards on its own.
615+
if self._skip_data_parallel_grad_sync:
616+
return
606617
for index, (sharding, lookup) in enumerate(
607618
zip(
608619
self._sharding_type_to_sharding.values(),
@@ -939,8 +950,9 @@ def _initialize_torch_state(self, skip_registering: bool = False) -> None: # no
939950
self._sharding_type_to_sharding.keys(), self._lookups
940951
):
941952
if sharding_type == ShardingType.DATA_PARALLEL.value:
942-
# unwrap DDP
943-
lookup = lookup.module
953+
# mark_data_parallel_skip_grad_sync modules are never wrapped.
954+
while isinstance(lookup, DistributedDataParallel):
955+
lookup = lookup.module
944956
else:
945957
# save local_shards for transforming MP params to shardedTensor
946958
for key, v in lookup.state_dict().items():
@@ -972,12 +984,9 @@ def _initialize_torch_state(self, skip_registering: bool = False) -> None: # no
972984
self._model_parallel_name_to_local_shards[table_name].extend(
973985
v.local_shards()
974986
)
975-
for (
976-
table_name,
977-
tbe_slice,
978-
# `named_parameters_by_table`.
979-
# pyrefly: ignore[missing-attribute]
980-
) in lookup.named_parameters_by_table():
987+
for table_name, tbe_slice in cast(
988+
GroupedEmbeddingsLookup, lookup
989+
).named_parameters_by_table():
981990
# for virtual table, currently we don't expose id tensor and bucket tensor
982991
# because they are not updated in real time, and they are created on the fly
983992
# whenever state_dict is called

torchrec/distributed/embeddingbag.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@
3737
from torch.nn.modules.module import _IncompatibleKeys
3838
from torch.nn.parallel import DistributedDataParallel
3939
from torchrec.distributed.comm import get_local_size
40-
from torchrec.distributed.embedding_lookup import PartiallyMaterializedTensor
40+
from torchrec.distributed.embedding_lookup import (
41+
GroupedPooledEmbeddingsLookup,
42+
PartiallyMaterializedTensor,
43+
)
4144
from torchrec.distributed.embedding_sharding import (
4245
EmbeddingSharding,
4346
EmbeddingShardingContext,
@@ -113,6 +116,7 @@
113116
from torchrec.modules.embedding_modules import (
114117
EmbeddingBagCollection,
115118
EmbeddingBagCollectionInterface,
119+
should_skip_data_parallel_grad_sync,
116120
)
117121
from torchrec.optim.fused import EmptyFusedOptimizer, FusedOptimizerModule
118122
from torchrec.optim.keyed import CombinedOptimizer, KeyedOptimizer
@@ -639,6 +643,10 @@ def __init__(
639643
# pyrefly: ignore [missing-attribute]
640644
super().__init__(qcomm_codecs_registry=qcomm_codecs_registry)
641645
self._module_fqn = module_fqn
646+
# Opt-out of the DATA_PARALLEL DDP gradient reducer (see embedding_modules).
647+
self._skip_data_parallel_grad_sync: bool = should_skip_data_parallel_grad_sync(
648+
module
649+
)
642650
# Normalize to lowercase for case-insensitive matching
643651
self._sharded_module_order_overwrite: Optional[List[str]] = (
644652
[s.lower() for s in sharded_module_order_overwrite]
@@ -815,6 +823,9 @@ def init_data_parallel(self) -> None:
815823
"""
816824
Initialize data parallel for the embedding bag collection.
817825
"""
826+
# Opt-out: skip the DDP wrap; the raw DP lookup forwards on its own.
827+
if self._skip_data_parallel_grad_sync:
828+
return
818829
for i, (sharding, lookup) in enumerate(
819830
zip(self._embedding_shardings, self._lookups)
820831
):
@@ -1213,8 +1224,9 @@ def _initialize_torch_state(self, skip_registering: bool = False) -> None: # no
12131224

12141225
for lookup, sharding in zip(self._lookups, self._embedding_shardings):
12151226
if isinstance(sharding, DpPooledEmbeddingSharding):
1216-
# unwrap DDP
1217-
lookup = lookup.module
1227+
# mark_data_parallel_skip_grad_sync modules are never wrapped.
1228+
while isinstance(lookup, DistributedDataParallel):
1229+
lookup = lookup.module
12181230
else:
12191231
# save local_shards for transforming MP params to DTensor
12201232
for key, v in lookup.state_dict().items():
@@ -1246,12 +1258,9 @@ def _initialize_torch_state(self, skip_registering: bool = False) -> None: # no
12461258
self._model_parallel_name_to_local_shards[table_name].extend(
12471259
v.local_shards()
12481260
)
1249-
for (
1250-
table_name,
1251-
tbe_slice,
1252-
# `named_parameters_by_table`.
1253-
# pyrefly: ignore [missing-attribute]
1254-
) in lookup.named_parameters_by_table():
1261+
for table_name, tbe_slice in cast(
1262+
GroupedPooledEmbeddingsLookup, lookup
1263+
).named_parameters_by_table():
12551264
# for virtual table, currently we don't expose id tensor and bucket tensor
12561265
# because they are not updated in real time, and they are created on the fly
12571266
# whenever state_dict is called
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
# pyre-strict
9+
10+
# This test exercises DATA_PARALLEL replication and the DDP-wrap decision, which
11+
# are device-independent; run it CPU/gloo-only so it needs no GPU. CUDA is hidden
12+
# per-test (see the class below) to keep MultiProcessContext on its CPU path
13+
# without mutating the process-wide env at import time.
14+
import os
15+
import unittest
16+
from typing import cast, List
17+
from unittest.mock import patch
18+
19+
import torch
20+
from torch import nn
21+
from torch.nn.parallel import DistributedDataParallel
22+
from torchrec.distributed.embedding import (
23+
EmbeddingCollectionSharder,
24+
ShardedEmbeddingCollection,
25+
)
26+
from torchrec.distributed.embeddingbag import (
27+
EmbeddingBagCollectionSharder,
28+
ShardedEmbeddingBagCollection,
29+
)
30+
from torchrec.distributed.model_parallel import DistributedModelParallel
31+
from torchrec.distributed.sharding_plan import (
32+
construct_module_sharding_plan,
33+
data_parallel,
34+
)
35+
from torchrec.distributed.test_utils.multi_process import (
36+
MultiProcessContext,
37+
MultiProcessTestBase,
38+
)
39+
from torchrec.distributed.types import ModuleSharder, ShardingEnv, ShardingPlan
40+
from torchrec.modules.embedding_configs import EmbeddingBagConfig, EmbeddingConfig
41+
from torchrec.modules.embedding_modules import (
42+
EmbeddingBagCollection,
43+
EmbeddingCollection,
44+
mark_data_parallel_skip_grad_sync,
45+
should_skip_data_parallel_grad_sync,
46+
)
47+
from torchrec.sparse.jagged_tensor import KeyedJaggedTensor
48+
from torchrec.test_utils import skip_if_asan_class
49+
50+
_TABLE_NAME = "table_0"
51+
_FEATURE_NAME = "feature_0"
52+
_EMBEDDING_DIM = 8
53+
_NUM_EMBEDDINGS = 16
54+
55+
56+
def _build_ebc(device: torch.device) -> EmbeddingBagCollection:
57+
return EmbeddingBagCollection(
58+
tables=[
59+
EmbeddingBagConfig(
60+
name=_TABLE_NAME,
61+
embedding_dim=_EMBEDDING_DIM,
62+
num_embeddings=_NUM_EMBEDDINGS,
63+
feature_names=[_FEATURE_NAME],
64+
)
65+
],
66+
device=device,
67+
)
68+
69+
70+
def _build_ec(device: torch.device) -> EmbeddingCollection:
71+
return EmbeddingCollection(
72+
tables=[
73+
EmbeddingConfig(
74+
name=_TABLE_NAME,
75+
embedding_dim=_EMBEDDING_DIM,
76+
num_embeddings=_NUM_EMBEDDINGS,
77+
feature_names=[_FEATURE_NAME],
78+
)
79+
],
80+
device=device,
81+
)
82+
83+
84+
def _input_kjt(device: torch.device) -> KeyedJaggedTensor:
85+
return KeyedJaggedTensor(
86+
keys=[_FEATURE_NAME],
87+
values=torch.tensor([1, 2, 3], dtype=torch.long, device=device),
88+
lengths=torch.tensor([2, 1], dtype=torch.long, device=device),
89+
)
90+
91+
92+
def _dp_lookups(dmp: DistributedModelParallel) -> List[nn.Module]:
93+
"""Lookups of the FIRST sharded embedding module found in the DMP. Each DMP in
94+
this test wraps exactly one EBC/EC, so returning the first match is sufficient."""
95+
for submodule in dmp.modules():
96+
if isinstance(
97+
submodule, (ShardedEmbeddingBagCollection, ShardedEmbeddingCollection)
98+
):
99+
# pyre-ignore[7]: private but stable attribute used for the assertion
100+
return list(submodule._lookups)
101+
raise AssertionError("no sharded embedding module found in DMP")
102+
103+
104+
def _resolve(output: object) -> object:
105+
return output.wait() if hasattr(output, "wait") else output
106+
107+
108+
def _shard_dp(
109+
module: nn.Module,
110+
sharder: ModuleSharder[nn.Module],
111+
ctx: MultiProcessContext,
112+
device: torch.device,
113+
) -> DistributedModelParallel:
114+
module_sharding_plan = construct_module_sharding_plan(
115+
module,
116+
per_param_sharding={_TABLE_NAME: data_parallel()},
117+
local_size=ctx.local_size,
118+
world_size=ctx.world_size,
119+
device_type=device.type,
120+
)
121+
return DistributedModelParallel(
122+
module=module,
123+
plan=ShardingPlan({"": module_sharding_plan}),
124+
# pyre-ignore[6]: ctx.pg is typed Optional[ProcessGroup]; MultiProcessContext
125+
# always initializes it before this call, so it is non-None here.
126+
env=ShardingEnv.from_process_group(ctx.pg),
127+
sharders=[sharder],
128+
device=device,
129+
)
130+
131+
132+
def _run_dp_grad_sync_optout(
133+
rank: int,
134+
world_size: int,
135+
backend: str,
136+
is_ebc: bool,
137+
) -> None:
138+
with MultiProcessContext(rank, world_size, backend) as ctx:
139+
# Force CPU/gloo: DATA_PARALLEL replication and the DDP-wrap decision are
140+
# device-independent, and this keeps the test runnable without GPUs.
141+
device = torch.device("cpu")
142+
143+
default_module = _build_ebc(device) if is_ebc else _build_ec(device)
144+
145+
frozen_module = _build_ebc(device) if is_ebc else _build_ec(device)
146+
# Opt the frozen copy out of the DATA_PARALLEL DDP gradient reducer.
147+
mark_data_parallel_skip_grad_sync(frozen_module)
148+
149+
sharder = cast(
150+
ModuleSharder[nn.Module],
151+
EmbeddingBagCollectionSharder() if is_ebc else EmbeddingCollectionSharder(),
152+
)
153+
154+
default_dmp = _shard_dp(default_module, sharder, ctx, device)
155+
frozen_dmp = _shard_dp(frozen_module, sharder, ctx, device)
156+
157+
# Regression guard: the default DP module still gets a DDP reducer.
158+
default_lookups = _dp_lookups(default_dmp)
159+
assert any(
160+
isinstance(lookup, DistributedDataParallel) for lookup in default_lookups
161+
), "default DATA_PARALLEL module must keep the DistributedDataParallel wrap"
162+
163+
# The frozen/opt-out module has raw lookups (no DDP, no reducer/buckets).
164+
frozen_lookups = _dp_lookups(frozen_dmp)
165+
assert not any(
166+
isinstance(lookup, DistributedDataParallel) for lookup in frozen_lookups
167+
), "opt-out DATA_PARALLEL module must skip the DistributedDataParallel wrap"
168+
169+
# The unwrapped DP lookup still forwards correctly (DDP is only needed
170+
# for the backward all-reduce). Check the pooled (EBC) forward against a
171+
# local (unsharded) reference that mirrors the sharded module's actual
172+
# replicated weight -- the sharded module re-inits in reset_parameters,
173+
# so copy its state, not the source module's. The embedding kernels have
174+
# no deterministic CUDA variant, so relax the deterministic guard.
175+
if is_ebc:
176+
weight_key = f"embedding_bags.{_TABLE_NAME}.weight"
177+
local_module = _build_ebc(device)
178+
local_module.load_state_dict(
179+
{weight_key: frozen_dmp.state_dict()[weight_key].cpu()}
180+
)
181+
kjt = _input_kjt(device)
182+
# Relax the deterministic guard only around the forward (embedding kernels
183+
# have no deterministic CPU/CUDA variant), then restore it -- the flag is
184+
# process-global even inside this MultiProcessContext subprocess.
185+
prev_deterministic = torch.are_deterministic_algorithms_enabled()
186+
torch.use_deterministic_algorithms(False)
187+
try:
188+
with torch.inference_mode():
189+
frozen_out = _resolve(frozen_dmp(kjt))
190+
local_out = local_module(kjt)
191+
torch.testing.assert_close(
192+
# pyre-ignore[16]: _resolve() returns object; the EBC forward output
193+
# (KeyedTensor) exposes .values().
194+
frozen_out.values(),
195+
# pyre-ignore[16]: local EBC forward returns a KeyedTensor (.values()).
196+
local_out.values(),
197+
)
198+
finally:
199+
torch.use_deterministic_algorithms(prev_deterministic)
200+
201+
202+
@skip_if_asan_class
203+
class DataParallelGradSyncOptOutTest(MultiProcessTestBase):
204+
def _run(self, is_ebc: bool) -> None:
205+
# Hide CUDA only for this test (not at module import, which would leak into
206+
# other test modules). The spawned workers inherit the env before they import
207+
# torch, forcing MultiProcessContext onto its CPU/gloo path.
208+
with patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}):
209+
self._run_multi_process_test(
210+
callable=_run_dp_grad_sync_optout,
211+
world_size=2,
212+
backend="gloo",
213+
is_ebc=is_ebc,
214+
)
215+
216+
def test_ebc_dp_skip_grad_sync(self) -> None:
217+
self._run(is_ebc=True)
218+
219+
def test_ec_dp_skip_grad_sync(self) -> None:
220+
self._run(is_ebc=False)
221+
222+
223+
class MarkDataParallelSkipGradSyncTest(unittest.TestCase):
224+
"""Unit tests for the marker helpers (no sharding / distributed setup needed)."""
225+
226+
def test_unmarked_module_returns_false(self) -> None:
227+
# A module never passed to mark_data_parallel_skip_grad_sync opts in to the
228+
# default (DDP reducer kept), so should_skip_data_parallel_grad_sync is False.
229+
module = _build_ebc(torch.device("cpu"))
230+
self.assertFalse(should_skip_data_parallel_grad_sync(module))
231+
232+
def test_marked_module_returns_true(self) -> None:
233+
module = _build_ebc(torch.device("cpu"))
234+
mark_data_parallel_skip_grad_sync(module)
235+
self.assertTrue(should_skip_data_parallel_grad_sync(module))
236+
237+
def test_unsharded_module_does_not_warn(self) -> None:
238+
module = _build_ebc(torch.device("cpu"))
239+
with self.assertNoLogs("torchrec.modules.embedding_modules", level="WARNING"):
240+
mark_data_parallel_skip_grad_sync(module)
241+
242+
def test_already_sharded_module_warns(self) -> None:
243+
# Marking after sharding is a no-op: the marker is read once at shard time.
244+
# `_lookups` (set by ShardedEmbeddingModule) stands in for a sharded module
245+
# so this stays a unit test with no distributed setup. Assigned through
246+
# object.__setattr__ because nn.Module.__setattr__ is typed to accept only
247+
# Module | Tensor, while the real attribute is a plain List[nn.Module].
248+
module = _build_ebc(torch.device("cpu"))
249+
object.__setattr__(module, "_lookups", [])
250+
with self.assertLogs(
251+
"torchrec.modules.embedding_modules", level="WARNING"
252+
) as cm:
253+
mark_data_parallel_skip_grad_sync(module)
254+
self.assertIn("already-sharded", cm.output[0])
255+
# Still sets the attribute -- the warning is advisory, not a guard.
256+
self.assertTrue(should_skip_data_parallel_grad_sync(module))

0 commit comments

Comments
 (0)